mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-04-04 06:51:45 -04:00
Compare commits
1859 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
1136c3f767 | ||
|
|
42b496cd77 | ||
|
|
4acb5ee020 | ||
|
|
ea18781cc6 | ||
|
|
593617c0ff | ||
|
|
c6a139d88d | ||
|
|
b7357a4546 | ||
|
|
5eac959d15 | ||
|
|
74c86ecfbe | ||
|
|
f353e590e1 | ||
|
|
a4cc3e10c2 | ||
|
|
7321f56ee2 | ||
|
|
8800d9adc6 | ||
|
|
22cd535527 | ||
|
|
1d0e9592df | ||
|
|
2ef4af0ff2 | ||
|
|
542a6b1592 | ||
|
|
613ef94dba | ||
|
|
1dc2a1fadf | ||
|
|
41a606f5c1 | ||
|
|
7b2b9855f9 | ||
|
|
b2b519ba2e | ||
|
|
5cf89392ff | ||
|
|
0f05304ec3 | ||
|
|
87bc962c88 | ||
|
|
546ce6e229 | ||
|
|
2163d5aaf6 | ||
|
|
905ea160f2 | ||
|
|
675f4a372b | ||
|
|
7ff42db0c6 | ||
|
|
a01283a446 | ||
|
|
fefa261e7d | ||
|
|
0447b22dd2 | ||
|
|
cf125c1b48 | ||
|
|
81a9d8257c | ||
|
|
ee3f471300 | ||
|
|
5c2e5f626d | ||
|
|
a0f4b62361 | ||
|
|
786166b448 | ||
|
|
66e198b4ef | ||
|
|
4919240242 | ||
|
|
d7e6a41e3f | ||
|
|
202ef737dd | ||
|
|
04993224dc | ||
|
|
bebe7c28f8 | ||
|
|
639991dde4 | ||
|
|
31404cb89a | ||
|
|
f6205ca1dd | ||
|
|
6e86fc0593 | ||
|
|
f39a9845a3 | ||
|
|
ba17582945 | ||
|
|
02a1cbd467 | ||
|
|
2cd102ef0b | ||
|
|
240361b55b | ||
|
|
9beabc93cd | ||
|
|
8f4c6b911a | ||
|
|
083ef3010d | ||
|
|
e6c2253219 | ||
|
|
d802eb3f28 | ||
|
|
a342d5d5ad | ||
|
|
99adb77fcb | ||
|
|
2ea4eae9d6 | ||
|
|
9b079b2c3a | ||
|
|
8648e8569e | ||
|
|
1be0ab8bcb | ||
|
|
718f76c1f2 | ||
|
|
155d1f4c06 | ||
|
|
cb79e27d5a | ||
|
|
26991f8dd8 | ||
|
|
2375330d76 | ||
|
|
94e9b6d99b | ||
|
|
b516d24101 | ||
|
|
1b131d9371 | ||
|
|
3f45ef192d | ||
|
|
c6684af521 | ||
|
|
52f12b81ff | ||
|
|
6630f787bf | ||
|
|
2d7b2da3e2 | ||
|
|
d3b008fcd9 | ||
|
|
8a62fd0e6a | ||
|
|
b044860f05 | ||
|
|
1c5786dfb6 | ||
|
|
6bc9e3d695 | ||
|
|
b74fe59f12 | ||
|
|
6b57aa7f14 | ||
|
|
227125b35c | ||
|
|
c4012d8dfc | ||
|
|
cf3fa9ffbc | ||
|
|
40640d029a | ||
|
|
01eb7038dc | ||
|
|
58115bfd11 | ||
|
|
f1ea5031fb | ||
|
|
26d15a9fb3 | ||
|
|
54ba8e6047 | ||
|
|
eca063ab75 | ||
|
|
8892f4144e | ||
|
|
d2c25f9d6c | ||
|
|
b57457dc2f | ||
|
|
2861b0cfa2 | ||
|
|
0c45dbb884 | ||
|
|
a9f9261fb7 | ||
|
|
7e5f54a4f1 | ||
|
|
1228e8759c | ||
|
|
1daf771218 | ||
|
|
880cb08c3d | ||
|
|
e2cbae3089 | ||
|
|
42dcc3318c | ||
|
|
b32a85ae7e | ||
|
|
af85edddca | ||
|
|
eccd88e3c2 | ||
|
|
e0e11629a1 | ||
|
|
968095c183 | ||
|
|
d32b5115c5 | ||
|
|
d3001ec887 | ||
|
|
fef6a52008 | ||
|
|
048e6affbc | ||
|
|
c653d49691 | ||
|
|
6f5c9bd054 | ||
|
|
9e5576244d | ||
|
|
ef91317232 | ||
|
|
10c44d050f | ||
|
|
1845ea7170 | ||
|
|
d453294622 | ||
|
|
d11f9e4971 | ||
|
|
08272dd04e | ||
|
|
42441b9b42 | ||
|
|
e4a293c046 | ||
|
|
0cc5a39d63 | ||
|
|
942ea3f125 | ||
|
|
a8a70bb71c | ||
|
|
0d7c3fb4b2 | ||
|
|
77c682454e | ||
|
|
dd3473f5d8 | ||
|
|
cceadc5e04 | ||
|
|
e48c3a3f9c | ||
|
|
14981ef077 | ||
|
|
a7858d44bd | ||
|
|
9ae5f27c04 | ||
|
|
d691129842 | ||
|
|
e26d551263 | ||
|
|
277c6a444f | ||
|
|
f344800fd6 | ||
|
|
39a6fba33f | ||
|
|
8e11657bd2 | ||
|
|
dfbeaa4edf | ||
|
|
e90dc3b7f4 | ||
|
|
dba89e611a | ||
|
|
1a3fecc89e | ||
|
|
407e6a3d06 | ||
|
|
6ee19d5359 | ||
|
|
2df424dbac | ||
|
|
9874be6bf1 | ||
|
|
a3d4199d1d | ||
|
|
247fa146a9 | ||
|
|
f2b2c02cd6 | ||
|
|
a915f27f00 | ||
|
|
e14a488934 | ||
|
|
e82a8d9bc3 | ||
|
|
4527a0d12b | ||
|
|
01be202484 | ||
|
|
d1fe99edc3 | ||
|
|
fa629591e9 | ||
|
|
4ab3edc97b | ||
|
|
f1bfc6bf55 | ||
|
|
3283843ef3 | ||
|
|
4cb14ec8cc | ||
|
|
41535a68be | ||
|
|
d62447a12a | ||
|
|
802367c914 | ||
|
|
ff9b2c6ee8 | ||
|
|
a0e25c941a | ||
|
|
091c99e784 | ||
|
|
e264bb407b | ||
|
|
16625210fc | ||
|
|
2550453ee4 | ||
|
|
d1c480f23f | ||
|
|
b4b0397589 | ||
|
|
ab6b34e84c | ||
|
|
87af9d5078 | ||
|
|
95fab7c395 | ||
|
|
90825925ff | ||
|
|
7036cf9e49 | ||
|
|
53123eb0ee | ||
|
|
3c5407dd51 | ||
|
|
1ffe81f740 | ||
|
|
6bb35d61e1 | ||
|
|
f36ccf7bdc | ||
|
|
4632e68a00 | ||
|
|
09858d0783 | ||
|
|
9d1423c41b | ||
|
|
1a4b7786dd | ||
|
|
77c0a21ad0 | ||
|
|
7cedf14121 | ||
|
|
235346f3dd | ||
|
|
34c36b7c3a | ||
|
|
3e0f788ec3 | ||
|
|
867bb8a072 | ||
|
|
31a400158a | ||
|
|
8106ff6489 | ||
|
|
de3508993c | ||
|
|
fd3e7a6f8a | ||
|
|
4cf97a6054 | ||
|
|
75036e3ec7 |
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)
|
||||
102
.coderabbit.yaml
Normal file
102
.coderabbit.yaml
Normal file
@@ -0,0 +1,102 @@
|
||||
# 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 resource files (useT())
|
||||
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.
|
||||
22
.env.example
22
.env.example
@@ -1,17 +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).
|
||||
|
||||
# Required variables for basic local functionality
|
||||
|
||||
# For database connection. A 16-character password with digits and letters.
|
||||
SUPABASE_DB_PASSWORD=
|
||||
|
||||
# For authentication.
|
||||
# Ask the project admin. Should start with "AIza".
|
||||
NEXT_PUBLIC_FIREBASE_API_KEY=
|
||||
|
||||
# The URL where your local backend server is running.
|
||||
# You can change the port if needed.
|
||||
NEXT_PUBLIC_API_URL=localhost:8088
|
||||
# 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
|
||||
|
||||
|
||||
# Optional variables for full local functionality
|
||||
@@ -20,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.
|
||||
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.).
|
||||
# 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
|
||||
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
branches: [main, master]
|
||||
paths:
|
||||
- 'package.json'
|
||||
- '.github/workflows/cd.yml'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
@@ -15,7 +17,7 @@ jobs:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@master
|
||||
with:
|
||||
fetch-depth: 0 # To fetch all history for tags
|
||||
fetch-depth: 0 # To fetch all history for tags
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
@@ -29,4 +31,4 @@ jobs:
|
||||
git config --global user.name "github-actions[bot]"
|
||||
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 }}
|
||||
./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,57 +2,52 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
name: All
|
||||
lint:
|
||||
name: Lint & Typecheck
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- 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
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup
|
||||
|
||||
- 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
|
||||
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:
|
||||
NEXT_PUBLIC_FIREBASE_ENV: DEV
|
||||
run: |
|
||||
NEXT_PUBLIC_API_URL=localhost:8088 \
|
||||
NEXT_PUBLIC_FIREBASE_ENV=PROD \
|
||||
NEXT_PUBLIC_FIREBASE_API_KEY=${{ secrets.NEXT_PUBLIC_FIREBASE_API_KEY }} \
|
||||
yarn --cwd=web serve &
|
||||
npx wait-on http://localhost:3000
|
||||
npx playwright test tests/playwright
|
||||
SERVER_PID=$(fuser -k 3000/tcp)
|
||||
echo $SERVER_PID
|
||||
kill $SERVER_PID
|
||||
yarn test:coverage
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
if: success()
|
||||
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
|
||||
|
||||
45
.gitignore
vendored
45
.gitignore
vendored
@@ -13,6 +13,10 @@
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# Playwright
|
||||
/tests/reports/playwright-report
|
||||
/tests/e2e/web/.auth/
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
@@ -35,7 +39,6 @@ yarn-error.log*
|
||||
.env.local
|
||||
.env.*
|
||||
.envrc
|
||||
supabase/*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
@@ -55,9 +58,47 @@ tsconfig.tsbuildinfo
|
||||
|
||||
*prisma/migrations
|
||||
martin
|
||||
email-preview
|
||||
.obsidian
|
||||
.idea
|
||||
*.last-run.json
|
||||
|
||||
*lock.hcl
|
||||
/web/pages/test.tsx
|
||||
/web/pages/_test.tsx
|
||||
|
||||
*.png
|
||||
*.jpg
|
||||
*.jpeg
|
||||
*.gif
|
||||
*.svg
|
||||
*.ico
|
||||
*.mp4
|
||||
*.mov
|
||||
*.webp
|
||||
*.avi
|
||||
*.wmv
|
||||
*.mp3
|
||||
*.wav
|
||||
*.flac
|
||||
*.aac
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.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
|
||||
|
||||
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,
|
||||
"useTabs": false,
|
||||
"semi": false,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"plugins": ["prettier-plugin-sql"],
|
||||
"singleAttributePerLine": false,
|
||||
"bracketSpacing": false,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "all",
|
||||
"plugins": ["prettier-plugin-sql", "prettier-plugin-packagejson"],
|
||||
"overrides": [
|
||||
{
|
||||
"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
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
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
|
||||
|
||||
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
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email
|
||||
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
|
||||
|
||||
## Enforcement Responsibilities
|
||||
@@ -106,7 +106,7 @@ Violating these terms may lead to a permanent ban.
|
||||
### 4. Permanent Ban
|
||||
|
||||
**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.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
|
||||
536
CONTRIBUTING.md
536
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.)
|
||||
- A functioning development environment
|
||||
- Node.js, Python, or other relevant runtime/tools installed (check the `README.md`)
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
- [Getting Started](#getting-started)
|
||||
- [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.
|
||||
2. **Clone your fork** locally:
|
||||
Please read and follow our [Code of Conduct](./CODE_OF_CONDUCT.md). We are committed to providing a welcoming and
|
||||
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
|
||||
git clone https://github.com/your-username/Compass.git
|
||||
cd your-fork
|
||||
|
||||
3. **Add the upstream remote**:
|
||||
|
||||
git clone https://github.com/<your-username>/Compass.git
|
||||
cd Compass
|
||||
```
|
||||
3. Add the upstream remote:
|
||||
```bash
|
||||
git remote add upstream https://github.com/CompassMeet/Compass.git
|
||||
git remote add upstream https://github.com/CompassConnections/Compass.git
|
||||
```
|
||||
|
||||
## Create a New Branch
|
||||
|
||||
Never work on `main` or `master`.
|
||||
### Install Dependencies
|
||||
|
||||
```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
|
||||
|
||||
Before you start, make sure your fork is up to date:
|
||||
### Running the Development Server
|
||||
|
||||
```bash
|
||||
git fetch upstream
|
||||
git checkout main
|
||||
git merge upstream/main
|
||||
yarn dev
|
||||
```
|
||||
|
||||
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
|
||||
git checkout fix/your-feature
|
||||
git rebase main
|
||||
yarn dev:isolated
|
||||
```
|
||||
|
||||
## 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
|
||||
type(scope): concise description
|
||||
Requirements:
|
||||
|
||||
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:
|
||||
|
||||
```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
|
||||
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
|
||||
# Example for Node.js
|
||||
npm test
|
||||
```
|
||||
### API Documentation
|
||||
|
||||
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
|
||||
npm run lint
|
||||
npm run format
|
||||
```
|
||||
- **Discord**: https://discord.gg/8Vd7jzqjun
|
||||
- **Email**: hello@compassmeet.com
|
||||
- **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.
|
||||
|
||||
348
README.md
348
README.md
@@ -1,13 +1,16 @@
|
||||
|
||||
[](https://github.com/CompassMeet/Compass/actions/workflows/ci.yml)
|
||||
[](https://github.com/CompassMeet/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
|
||||
|
||||
This repository provides the source code for [Compass](https://compassmeet.com), a web application for people to form deep 1-on-1 relationships in a fully transparent and efficient way. And it just got released!
|
||||
|
||||
**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/about) in any way you can and help our community thrive!
|
||||
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.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -15,13 +18,51 @@ This repository provides the source code for [Compass](https://compassmeet.com),
|
||||
- Radically transparent: user base fully searchable
|
||||
- Free, ad-free, not for profit (supported by donations)
|
||||
- Created, hosted, maintained, and moderated by volunteers
|
||||
- Open source
|
||||
- Open-source
|
||||
- Democratically governed
|
||||
|
||||
A detailed description of the vision is available [here](https://martinbraquet.com/meeting-rational).
|
||||
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.
|
||||
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
|
||||
|
||||
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**!
|
||||
|
||||
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] Set up PostgreSQL in Production with supabase
|
||||
- [x] Set up web hosting (vercel)
|
||||
@@ -29,20 +70,42 @@ A detailed description of the vision is available [here](https://martinbraquet.c
|
||||
- [x] Ask for detailed info upon registration (location, desired type of connection, prompt answers, gender, etc.)
|
||||
- [x] Set up page listing all the profiles
|
||||
- [x] Search through most profile variables
|
||||
- [x] (Set up chat / direct messaging)
|
||||
- [x] Set up domain name (https://compassmeet.com)
|
||||
- [x] Set up chat / direct messaging
|
||||
- [x] Set up domain name (compassmeet.com)
|
||||
- [ ] Cover more than 90% with tests (unit, integration, e2e)
|
||||
- [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 improve interpersonal skills (active listening, nonviolent communication, etc.)
|
||||
- [ ] Add calendar integration and scheduling
|
||||
- [x] Add events (group calls, in-person meetups, etc.)
|
||||
|
||||
#### Secondary To Do
|
||||
|
||||
Any action item 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.
|
||||
|
||||
- [ ] Add profile features (intellectual interests, cause areas, personality type, conflict style, etc.)
|
||||
- [ ] Add filters to search through remaining profile features (politics, religion, education level, etc.)
|
||||
- [ ] Cover with tests (very important, just the test template and framework are ready)
|
||||
- [ ] Clean up terms and conditions
|
||||
- [ ] Clean up privacy notice
|
||||
- [x] Clean up learn more page
|
||||
- [x] Add dark theme
|
||||
- [x] Add profile fields (intellectual interests, cause areas, personality type, etc.)
|
||||
- [ ] Add profile fields: conflict style
|
||||
- [ ] 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)
|
||||
- [ ] Clean up terms and conditions (convert to Markdown)
|
||||
- [ ] Clean up privacy notice (convert to Markdown)
|
||||
- [ ] Add other authentication methods (GitHub, Facebook, Apple, phone, etc.)
|
||||
- [x] Add email verification
|
||||
- [x] Add password reset
|
||||
- [x] Add automated welcome email
|
||||
- [ ] Security audit and penetration testing
|
||||
- [x] Make `deploy-api.sh` run automatically on push to `main` branch
|
||||
- [x] Create settings page (change email, password, delete account, etc.)
|
||||
- [ ] Improve [financials](web/public/md/financials.md) page (donor / acknowledgments, etc.)
|
||||
- [x] Improve loading sign (e.g., animation of a compass moving around)
|
||||
- [x] Show compatibility score in profile page
|
||||
|
||||
## Implementation
|
||||
|
||||
@@ -55,95 +118,226 @@ The web app is coded in Typescript using React as front-end. It includes:
|
||||
|
||||
## Development
|
||||
|
||||
Below are all 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
|
||||
|
||||
Clone the 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
|
||||
git clone git@github.com:CompassMeet/Compass.git
|
||||
git clone https://github.com/<your-username>/Compass.git
|
||||
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
|
||||
./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:
|
||||
|
||||
```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
|
||||
|
||||
So, for your development, we will give you user-specific access when possible (e.g., Firebase) and for the rest you will need to set up cloned services (email, locations, etc.) and store your secrets as environment variables.
|
||||
|
||||
To do so, simply create an `.env` file as a copy of `.env.example`, open it, and fill in the variables according to the instructions in the file:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
### Installing PostgreSQL
|
||||
|
||||
Run the following commands to set up your local development database. Run only the section that corresponds to your operating system.
|
||||
|
||||
On macOS:
|
||||
```bash
|
||||
brew install postgresql
|
||||
brew services start postgresql
|
||||
```
|
||||
|
||||
On Linux:
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install postgresql postgresql-contrib
|
||||
sudo systemctl start postgresql
|
||||
````
|
||||
|
||||
On Windows, you can download PostgreSQL from the [official website](https://www.postgresql.org/download/windows/).
|
||||
|
||||
### Database Initialization
|
||||
|
||||
Create a database named `compass` and set the password for the `postgres` user:
|
||||
```bash
|
||||
sudo -u postgres psql
|
||||
ALTER USER postgres WITH PASSWORD 'password';
|
||||
\q
|
||||
```
|
||||
|
||||
Create the database
|
||||
```bash
|
||||
...
|
||||
```
|
||||
Note that your local database will be made of synthetic data, not real users. This is fine for development and testing.
|
||||
|
||||
### Tests
|
||||
|
||||
Make sure the tests pass:
|
||||
Make sure the Jest tests pass:
|
||||
|
||||
```bash
|
||||
yarn test
|
||||
```
|
||||
TODO: fix tests
|
||||
|
||||
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
|
||||
|
||||
Start the development server:
|
||||
|
||||
```bash
|
||||
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 5 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
|
||||
|
||||
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
|
||||
|
||||
Add the original repo as upstream for syncing:
|
||||
|
||||
```bash
|
||||
git remote add upstream https://github.com/CompassConnections/Compass.git
|
||||
```
|
||||
|
||||
Create a new branch for your changes:
|
||||
|
||||
```bash
|
||||
git checkout -b <branch-name>
|
||||
```
|
||||
|
||||
Make changes, then stage and commit:
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Describe your changes"
|
||||
```
|
||||
|
||||
Push branch to your fork:
|
||||
|
||||
```bash
|
||||
git push origin <branch-name>
|
||||
```
|
||||
|
||||
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
|
||||
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 |
|
||||
| ------- | ------------------ |
|
||||
| 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
|
||||
|
||||
Contact the development team 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
|
||||
@@ -1,19 +1,23 @@
|
||||
# prereq: first do `yarn build` to compile typescript & etc.
|
||||
|
||||
FROM node:19-alpine
|
||||
FROM node:20-alpine
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Install PM2 globally
|
||||
RUN yarn global add pm2
|
||||
|
||||
# Remove?
|
||||
COPY tsconfig.json ./
|
||||
|
||||
# first get dependencies in for efficient docker layering
|
||||
# Fet dependencies in for efficient docker layering
|
||||
COPY dist/package.json dist/yarn.lock ./
|
||||
RUN yarn install --frozen-lockfile --production
|
||||
|
||||
# then copy over typescript payload
|
||||
# Clean yarn cache to reduce image size
|
||||
RUN yarn install --frozen-lockfile --production && \
|
||||
yarn cache clean --force && \
|
||||
rm -rf /usr/local/share/.cache/yarn
|
||||
|
||||
# Show installed packages
|
||||
RUN npm list || true
|
||||
|
||||
# Copy over typescript payload
|
||||
COPY dist ./
|
||||
|
||||
# Copy the PM2 ecosystem configuration
|
||||
|
||||
@@ -1,33 +1,451 @@
|
||||
# Backend API
|
||||
|
||||
This is the code for the API running at `api.compassmeet.com`.
|
||||
It runs in a docker inside a Google Cloud virtual machine.
|
||||
Express.js REST API for Compass, running at https://api.compassmeet.com.
|
||||
|
||||
### 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:
|
||||
```bash
|
||||
brew install --cask google-cloud-sdk
|
||||
```
|
||||
|
||||
On Linux:
|
||||
|
||||
```bash
|
||||
sudo apt-get update && sudo apt-get install google-cloud-sdk
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
gcloud init
|
||||
gcloud auth login
|
||||
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
|
||||
|
||||
This section is only for the people who are creating a server from scratch, for instance for a forked project.
|
||||
|
||||
One-time commands you may need to run:
|
||||
|
||||
```bash
|
||||
gcloud artifacts repositories create builds \
|
||||
--repository-format=docker \
|
||||
@@ -49,17 +467,59 @@ gcloud projects add-iam-policy-binding compass-130ba \
|
||||
--member="serviceAccount:253367029065-compute@developer.gserviceaccount.com" \
|
||||
--role="roles/secretmanager.secretAccessor"
|
||||
gcloud run services list
|
||||
gcloud compute backend-services update api-backend \
|
||||
--global \
|
||||
--timeout=600s
|
||||
```
|
||||
|
||||
Set up the saved search notifications job:
|
||||
|
||||
```bash
|
||||
gcloud scheduler jobs create http daily-saved-search-notifications \
|
||||
--schedule="0 16 * * *" \
|
||||
--uri="https://api.compassmeet.com/internal/send-search-notifications" \
|
||||
--http-method=POST \
|
||||
--headers="x-api-key=<API_KEY>" \
|
||||
--time-zone="UTC" \
|
||||
--location=us-west1
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
* After deployment, Terraform assigns a static external IP to this resource.
|
||||
* You can get it manually:
|
||||
- After deployment, Terraform assigns a static external IP to this resource.
|
||||
- You can get it manually:
|
||||
|
||||
```bash
|
||||
gcloud compute addresses describe api-lb-ip-2 --global --format="get(address)"
|
||||
34.117.20.215
|
||||
```
|
||||
|
||||
Since Vercel manages your domain (`compassmeet.com`):
|
||||
|
||||
1. Log in to [Vercel dashboard](https://vercel.com/dashboard).
|
||||
@@ -70,8 +530,8 @@ Since Vercel manages your domain (`compassmeet.com`):
|
||||
| ---- | ---- | ------------ | ----- |
|
||||
| A | api | 34.123.45.67 | 600 s |
|
||||
|
||||
* `Name` is just the subdomain: `api` → `api.compassmeet.com`.
|
||||
* `Value` is the **external IP of the LB** from step 1.
|
||||
- `Name` is just the subdomain: `api` → `api.compassmeet.com`.
|
||||
- `Value` is the **external IP of the LB** from step 1.
|
||||
|
||||
Verify connectivity
|
||||
From your local machine:
|
||||
@@ -82,9 +542,8 @@ ping -c 3 api.compassmeet.com
|
||||
curl -I https://api.compassmeet.com
|
||||
```
|
||||
|
||||
* `nslookup` should return the LB IP (`34.123.45.67`).
|
||||
* `curl -I` should return `200 OK` from your service.
|
||||
|
||||
- `nslookup` should return the LB IP (`34.123.45.67`).
|
||||
- `curl -I` should return `200 OK` from your service.
|
||||
|
||||
If SSL isn’t ready (may take 15 mins), check LB logs:
|
||||
|
||||
@@ -96,36 +555,8 @@ gcloud compute ssl-certificates describe api-lb-cert-2
|
||||
|
||||
Secrets are strings that shouldn't be checked into Git (eg API keys, passwords).
|
||||
|
||||
Add the secrets for your specific project in [Google Cloud Secrets manager](https://console.cloud.google.com/security/secret-manager), so that the virtual machine can access them.
|
||||
Add the secrets for your specific project
|
||||
in [Google Cloud Secrets manager](https://console.cloud.google.com/security/secret-manager), so that the virtual machine
|
||||
can access them.
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
source ../../.env
|
||||
|
||||
ENV=${1:-prod}
|
||||
@@ -23,16 +25,31 @@ PROJECT="compass-130ba"
|
||||
SERVICE_NAME="api"
|
||||
|
||||
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")
|
||||
IMAGE_TAG="${TIMESTAMP}-${GIT_REVISION}"
|
||||
IMAGE_URL="${REGION}-docker.pkg.dev/${PROJECT}/builds/${SERVICE_NAME}:${IMAGE_TAG}"
|
||||
|
||||
echo "🚀 Deploying ${SERVICE_NAME} to ${ENV} ($(date "+%Y-%m-%d %I:%M:%S %p"))"
|
||||
yarn add tsconfig-paths
|
||||
yarn build
|
||||
|
||||
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}"
|
||||
docker push ${IMAGE_URL}
|
||||
|
||||
@@ -40,16 +57,16 @@ export TF_VAR_image_url=$IMAGE_URL
|
||||
export TF_VAR_env=$ENV
|
||||
tofu apply -auto-approve
|
||||
|
||||
INSTANCE_NAME=$(gcloud compute instances list \
|
||||
--filter="zone:(us-west1-c)" \
|
||||
--sort-by="~creationTimestamp" \
|
||||
--format="value(name)" \
|
||||
--limit=1)
|
||||
SERVICE_ACCOUNT_EMAIL=$(gcloud compute instances describe ${INSTANCE_NAME} \
|
||||
--zone us-west1-c \
|
||||
--format="value(serviceAccounts.email)")
|
||||
gcloud projects add-iam-policy-binding ${PROJECT} \
|
||||
--member="serviceAccount:$SERVICE_ACCOUNT_EMAIL" \
|
||||
--role="roles/artifactregistry.reader"
|
||||
#INSTANCE_NAME=$(gcloud compute instances list \
|
||||
# --filter="zone:(us-west1-c)" \
|
||||
# --sort-by="~creationTimestamp" \
|
||||
# --format="value(name)" \
|
||||
# --limit=1)
|
||||
#SERVICE_ACCOUNT_EMAIL=$(gcloud compute instances describe ${INSTANCE_NAME} \
|
||||
# --zone us-west1-c \
|
||||
# --format="value(serviceAccounts.email)")
|
||||
#gcloud projects add-iam-policy-binding ${PROJECT} \
|
||||
# --member="serviceAccount:$SERVICE_ACCOUNT_EMAIL" \
|
||||
# --role="roles/artifactregistry.reader"
|
||||
|
||||
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 = {
|
||||
apps: [
|
||||
{
|
||||
name: "api",
|
||||
script: "node",
|
||||
args: "-r tsconfig-paths/register --dns-result-order=ipv4first backend/api/lib/serve.js",
|
||||
env: {
|
||||
NODE_ENV: "production",
|
||||
NODE_PATH: "/usr/src/app/node_modules", // <- ensures Node finds tsconfig-paths
|
||||
PORT: 80,
|
||||
},
|
||||
instances: 1,
|
||||
exec_mode: "fork",
|
||||
autorestart: true,
|
||||
watch: false,
|
||||
// 4 GB on the box, give 3 GB to the JS heap
|
||||
node_args: "--max-old-space-size=3072",
|
||||
max_memory_restart: "3500M"
|
||||
}
|
||||
]
|
||||
};
|
||||
apps: [
|
||||
{
|
||||
name: 'api',
|
||||
script: 'node',
|
||||
args: '--dns-result-order=ipv4first backend/api/lib/serve.js',
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
NODE_PATH: '/usr/src/app/node_modules', // <- ensures Node finds tsconfig-paths
|
||||
PORT: 80,
|
||||
},
|
||||
instances: 1,
|
||||
exec_mode: 'fork',
|
||||
autorestart: true,
|
||||
watch: false,
|
||||
// 4 GB on the box, give 3 GB to the JS heap
|
||||
node_args: '--max-old-space-size=3072',
|
||||
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 {
|
||||
project = "compass-130ba"
|
||||
region = "us-west1"
|
||||
project = "compass-130ba"
|
||||
region = "us-west1"
|
||||
zone = "us-west1-b"
|
||||
service_name = "api"
|
||||
machine_type = "e2-small"
|
||||
@@ -55,7 +55,7 @@ resource "google_storage_bucket" "public_storage" {
|
||||
|
||||
# static IPs
|
||||
resource "google_compute_global_address" "api_lb_ip" {
|
||||
name = "api-lb-ip-2"
|
||||
name = "api-lb-ip-2"
|
||||
address_type = "EXTERNAL"
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ resource "google_compute_instance_template" "api_template" {
|
||||
}
|
||||
|
||||
network_interface {
|
||||
network = "default"
|
||||
network = "default"
|
||||
subnetwork = "default"
|
||||
access_config {
|
||||
network_tier = "PREMIUM"
|
||||
@@ -105,6 +105,7 @@ spec:
|
||||
ports:
|
||||
- containerPort: 80
|
||||
EOF
|
||||
google-logging-enabled = "true"
|
||||
}
|
||||
|
||||
lifecycle {
|
||||
@@ -116,12 +117,12 @@ EOF
|
||||
resource "google_compute_region_instance_group_manager" "api_group" {
|
||||
name = "${local.service_name}-group"
|
||||
base_instance_name = "${local.service_name}-group"
|
||||
region = local.region
|
||||
region = local.region
|
||||
target_size = 1
|
||||
|
||||
version {
|
||||
instance_template = google_compute_instance_template.api_template.id
|
||||
name = "primary"
|
||||
name = "primary"
|
||||
}
|
||||
|
||||
update_policy {
|
||||
@@ -175,7 +176,7 @@ resource "google_compute_backend_service" "api_backend" {
|
||||
# URL map
|
||||
resource "google_compute_url_map" "api_url_map" {
|
||||
name = "${local.service_name}-url-map"
|
||||
default_service = google_compute_backend_service.api_backend.id
|
||||
default_service = google_compute_backend_service.api_backend.self_link
|
||||
|
||||
host_rule {
|
||||
hosts = ["*"]
|
||||
@@ -185,9 +186,33 @@ resource "google_compute_url_map" "api_url_map" {
|
||||
path_matcher {
|
||||
name = "allpaths"
|
||||
default_service = google_compute_backend_service.api_backend.self_link
|
||||
#
|
||||
# # Priority 0: passthrough /v0/* requests
|
||||
# route_rules {
|
||||
# priority = 1
|
||||
# match_rules {
|
||||
# prefix_match = "/v0"
|
||||
# }
|
||||
# service = google_compute_backend_service.api_backend.self_link
|
||||
# }
|
||||
#
|
||||
# # Priority 1: rewrite everything else to /v0
|
||||
# route_rules {
|
||||
# priority = 2
|
||||
# match_rules {
|
||||
# prefix_match = "/"
|
||||
# }
|
||||
# route_action {
|
||||
# url_rewrite { # This may break websockets (the Upgrade and Connection headers must pass through untouched).
|
||||
# path_prefix_rewrite = "/v0/"
|
||||
# }
|
||||
# }
|
||||
# service = google_compute_backend_service.api_backend.self_link
|
||||
# }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# HTTPS proxy
|
||||
resource "google_compute_target_https_proxy" "api_https_proxy" {
|
||||
name = "${local.service_name}-https-proxy"
|
||||
@@ -243,10 +268,10 @@ resource "google_compute_firewall" "allow_health_check" {
|
||||
}
|
||||
|
||||
resource "google_compute_firewall" "default_allow_https" {
|
||||
name = "default-allow-http"
|
||||
network = "default"
|
||||
priority = 1000
|
||||
direction = "INGRESS"
|
||||
name = "default-allow-http"
|
||||
network = "default"
|
||||
priority = 1000
|
||||
direction = "INGRESS"
|
||||
|
||||
allow {
|
||||
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,68 +1,66 @@
|
||||
{
|
||||
"name": "@compass/api",
|
||||
"description": "Backend API endpoints",
|
||||
"version": "0.1.0",
|
||||
"version": "1.30.3",
|
||||
"private": true,
|
||||
"description": "Backend API endpoints",
|
||||
"main": "src/serve.ts",
|
||||
"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: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\"",
|
||||
"dev": "yarn watch:serve",
|
||||
"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: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",
|
||||
"watch": "tsc -w",
|
||||
"verify": "yarn --cwd=../.. verify",
|
||||
"verify:dir": "npx eslint . --max-warnings 0",
|
||||
"dist:copy": "./dist_copy.sh",
|
||||
"lint": "npx eslint . --max-warnings 0",
|
||||
"lint-fix": "npx eslint . --fix",
|
||||
"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-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": ">=16.0.0"
|
||||
},
|
||||
"main": "src/serve.ts",
|
||||
"dependencies": {
|
||||
"@google-cloud/monitoring": "4.0.0",
|
||||
"@google-cloud/secret-manager": "4.2.1",
|
||||
"@react-email/components": "0.0.33",
|
||||
"@supabase/supabase-js": "2.38.5",
|
||||
"@tiptap/core": "2.3.2",
|
||||
"@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",
|
||||
"@mozilla/readability": "0.6.0",
|
||||
"@sentry/node": "10.41.0",
|
||||
"@tiptap/core": "2.10.4",
|
||||
"cors": "2.8.5",
|
||||
"dayjs": "1.11.4",
|
||||
"express": "4.18.1",
|
||||
"firebase-admin": "11.11.1",
|
||||
"dayjs": "1.11.19",
|
||||
"express": "5.0.0",
|
||||
"firebase-admin": "13.5.0",
|
||||
"gcp-metadata": "6.1.0",
|
||||
"jsdom": "29.0.1",
|
||||
"jsonwebtoken": "9.0.0",
|
||||
"lodash": "4.17.21",
|
||||
"pg-promise": "11.4.1",
|
||||
"lodash": "4.17.23",
|
||||
"marked": "17.0.5",
|
||||
"openapi-types": "12.1.3",
|
||||
"pg-promise": "12.6.1",
|
||||
"posthog-node": "4.11.0",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-email": "3.0.7",
|
||||
"resend": "4.1.2",
|
||||
"string-similarity": "4.0.4",
|
||||
"swagger-jsdoc": "6.2.8",
|
||||
"swagger-ui-express": "5.0.1",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"twitter-api-v2": "1.15.0",
|
||||
"ws": "8.17.0",
|
||||
"zod": "3.21.4"
|
||||
"web-push": "3.6.7",
|
||||
"ws": "8.17.1",
|
||||
"zod": "^3.25"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "2.8.17",
|
||||
"@types/jsdom": "28.0.1",
|
||||
"@types/jsonwebtoken": "^9.0.0",
|
||||
"@types/lodash": "^4.17.0",
|
||||
"@types/swagger-ui-express": "4.1.8",
|
||||
"@types/web-push": "3.6.4",
|
||||
"@types/ws": "8.5.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,57 +1,110 @@
|
||||
import { API, type APIPath } from 'common/api/schema'
|
||||
import { APIError, pathWithPrefix } from 'common/api/utils'
|
||||
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 {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 * as crypto from 'crypto'
|
||||
import express from 'express'
|
||||
import { type ErrorRequestHandler, type RequestHandler } from 'express'
|
||||
import { hrtime } from 'node:process'
|
||||
import { withMonitoringContext } from 'shared/monitoring/context'
|
||||
import { log } from 'shared/monitoring/log'
|
||||
import { metrics } from 'shared/monitoring/metrics'
|
||||
import { banUser } from './ban-user'
|
||||
import { blockUser, unblockUser } from './block-user'
|
||||
import { getCompatibleLoversHandler } from './compatible-lovers'
|
||||
import { createComment } from './create-comment'
|
||||
import { createCompatibilityQuestion } from './create-compatibility-question'
|
||||
import { createLover } from './create-lover'
|
||||
import { createUser } from './create-user'
|
||||
import { getCompatibilityQuestions } from './get-compatibililty-questions'
|
||||
import { getLikesAndShips } from './get-likes-and-ships'
|
||||
import { getLoverAnswers } from './get-lover-answers'
|
||||
import { getLovers } from './get-lovers'
|
||||
import { getSupabaseToken } from './get-supabase-token'
|
||||
import { getDisplayUser, getUser } from './get-user'
|
||||
import { getMe } from './get-me'
|
||||
import { hasFreeLike } from './has-free-like'
|
||||
import { health } from './health'
|
||||
import { typedEndpoint, type APIHandler } from './helpers/endpoint'
|
||||
import { hideComment } from './hide-comment'
|
||||
import { likeLover } from './like-lover'
|
||||
import { markAllNotifsRead } from './mark-all-notifications-read'
|
||||
import { removePinnedPhoto } from './remove-pinned-photo'
|
||||
import { report } from './report'
|
||||
import { searchLocation } from './search-location'
|
||||
import { searchNearCity } from './search-near-city'
|
||||
import { shipLovers } from './ship-lovers'
|
||||
import { starLover } from './star-lover'
|
||||
import { updateLover } from './update-lover'
|
||||
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 express, {type ErrorRequestHandler, type RequestHandler} from 'express'
|
||||
import {OpenAPIV3} from 'openapi-types'
|
||||
import {withMonitoringContext} from 'shared/monitoring/context'
|
||||
import {log} from 'shared/monitoring/log'
|
||||
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 {blockUser, unblockUser} from './block-user'
|
||||
import {cancelEvent} from './cancel-event'
|
||||
import {cancelRsvp} from './cancel-rsvp'
|
||||
import {getCompatibleProfilesHandler} from './compatible-profiles'
|
||||
import {createBookmarkedSearch} from './create-bookmarked-search'
|
||||
import {createComment} from './create-comment'
|
||||
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 {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 {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 {getMe} from './get-me'
|
||||
import {getNotifications} from './get-notifications'
|
||||
import {getProfileAnswers} from './get-profile-answers'
|
||||
import {getProfiles} from './get-profiles'
|
||||
import {getSupabaseToken} from './get-supabase-token'
|
||||
import {getUserAndProfileHandler} from './get-user-and-profile'
|
||||
import {getUserDataExport} from './get-user-data-export'
|
||||
import {hasFreeLike} from './has-free-like'
|
||||
import {health} from './health'
|
||||
import {type APIHandler, typedEndpoint} from './helpers/endpoint'
|
||||
import {hideComment} from './hide-comment'
|
||||
import {leavePrivateUserMessageChannel} from './leave-private-user-message-channel'
|
||||
import {likeProfile} from './like-profile'
|
||||
import {llmExtractProfileEndpoint} from './llm-extract-profile'
|
||||
import {markAllNotifsRead} from './mark-all-notifications-read'
|
||||
import {removePinnedPhoto} from './remove-pinned-photo'
|
||||
import {report} from './report'
|
||||
import {rsvpEvent} from './rsvp-event'
|
||||
import {searchLocationEndpoint} from './search-location'
|
||||
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 {starProfile} from './star-profile'
|
||||
import {stats} from './stats'
|
||||
import {updateEvent} from './update-event'
|
||||
import {updateMe} from './update-me'
|
||||
import {updateNotifSettings} from './update-notif-setting'
|
||||
import {updatePrivateUserMessageChannel} from './update-private-user-message-channel'
|
||||
import {updateProfileEndpoint} from './update-profile'
|
||||
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({})
|
||||
|
||||
function cacheController(policy?: string): RequestHandler {
|
||||
@@ -63,18 +116,18 @@ function cacheController(policy?: string): RequestHandler {
|
||||
|
||||
const requestMonitoring: RequestHandler = (req, _res, next) => {
|
||||
const traceContext = req.get('X-Cloud-Trace-Context')
|
||||
const traceId = traceContext
|
||||
? traceContext.split('/')[0]
|
||||
: crypto.randomUUID()
|
||||
const context = { endpoint: req.path, traceId }
|
||||
const traceId = traceContext ? traceContext.split('/')[0] : crypto.randomUUID()
|
||||
const context = {endpoint: req.path, traceId}
|
||||
withMonitoringContext(context, () => {
|
||||
const startTs = hrtime.bigint()
|
||||
log(`${req.method} ${req.url}`)
|
||||
metrics.inc('http/request_count', { endpoint: req.path })
|
||||
metrics.inc('http/request_count', {endpoint: req.path})
|
||||
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 latencyMs = Number(endTs - startTs) / 1e6
|
||||
metrics.push('http/request_latency', latencyMs, { endpoint: req.path })
|
||||
metrics.push('http/request_latency', latencyMs, {endpoint: req.path})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -82,16 +135,16 @@ const apiErrorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
|
||||
if (error instanceof APIError) {
|
||||
log.info(error)
|
||||
if (!res.headersSent) {
|
||||
const output: { [k: string]: unknown } = { message: error.message }
|
||||
if (error.details != null) {
|
||||
output.details = error.details
|
||||
}
|
||||
res.status(error.code).json(output)
|
||||
res.status(error.code).json(error.toJSON())
|
||||
}
|
||||
} else {
|
||||
log.error(error)
|
||||
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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,63 +152,526 @@ const apiErrorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
|
||||
export const app = express()
|
||||
app.use(requestMonitoring)
|
||||
|
||||
app.options('*', allowCorsUnrestricted)
|
||||
const schemaCache = new WeakMap<ZodTypeAny, any>()
|
||||
|
||||
const handlers: { [k in APIPath]: APIHandler<k> } = {
|
||||
health: health,
|
||||
'get-supabase-token': getSupabaseToken,
|
||||
'get-notifications': getNotifications,
|
||||
'mark-all-notifs-read': markAllNotifsRead,
|
||||
'user/:username': getUser,
|
||||
'user/:username/lite': getDisplayUser,
|
||||
'user/by-id/:id': getUser,
|
||||
'user/by-id/:id/lite': getDisplayUser,
|
||||
'user/by-id/:id/block': blockUser,
|
||||
'user/by-id/:id/unblock': unblockUser,
|
||||
'search-users': searchUsers,
|
||||
export function zodToOpenApiSchema(zodObj: ZodTypeAny): any {
|
||||
if (schemaCache.has(zodObj)) {
|
||||
return schemaCache.get(zodObj)
|
||||
}
|
||||
|
||||
const def: any = (zodObj as any)._def
|
||||
const typeName = def.typeName as ZodFirstPartyTypeKind
|
||||
|
||||
// Placeholder so recursive references can point here
|
||||
const placeholder: any = {}
|
||||
schemaCache.set(zodObj, placeholder)
|
||||
|
||||
let schema: any
|
||||
|
||||
switch (typeName) {
|
||||
case 'ZodString':
|
||||
schema = {type: 'string'}
|
||||
break
|
||||
case 'ZodNumber':
|
||||
schema = {type: 'number'}
|
||||
break
|
||||
case 'ZodBoolean':
|
||||
schema = {type: 'boolean'}
|
||||
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,
|
||||
report: report,
|
||||
'create-user': createUser,
|
||||
'create-lover': createLover,
|
||||
me: getMe,
|
||||
'me/private': getCurrentPrivateUser,
|
||||
'me/update': updateMe,
|
||||
'update-notif-settings': updateNotifSettings,
|
||||
'me/delete': deleteMe,
|
||||
'update-lover': updateLover,
|
||||
'like-lover': likeLover,
|
||||
'ship-lovers': shipLovers,
|
||||
'get-likes-and-ships': getLikesAndShips,
|
||||
'has-free-like': hasFreeLike,
|
||||
'star-lover': starLover,
|
||||
'get-lovers': getLovers,
|
||||
'get-lover-answers': getLoverAnswers,
|
||||
'get-compatibility-questions': getCompatibilityQuestions,
|
||||
'remove-pinned-photo': removePinnedPhoto,
|
||||
'compatible-profiles': getCompatibleProfilesHandler,
|
||||
contact: contact,
|
||||
'create-bookmarked-search': createBookmarkedSearch,
|
||||
'create-comment': createComment,
|
||||
'hide-comment': hideComment,
|
||||
'create-compatibility-question': createCompatibilityQuestion,
|
||||
'compatible-lovers': getCompatibleLoversHandler,
|
||||
'search-location': searchLocation,
|
||||
'search-near-city': searchNearCity,
|
||||
'create-private-user-message': createPrivateUserMessage,
|
||||
'create-private-user-message-channel': createPrivateUserMessageChannel,
|
||||
'update-private-user-message-channel': updatePrivateUserMessageChannel,
|
||||
'leave-private-user-message-channel': leavePrivateUserMessageChannel,
|
||||
'create-profile': createProfile,
|
||||
'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-messages': getChannelMessages,
|
||||
'get-channel-messages': getChannelMessagesEndpoint,
|
||||
'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-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]) => {
|
||||
const api = API[path as APIPath]
|
||||
const cache = cacheController((api as any).cache)
|
||||
const url = '/' + pathWithPrefix(path as APIPath)
|
||||
const url = pathWithPrefix(('/' + path) as APIPath)
|
||||
|
||||
const apiRoute = [
|
||||
url,
|
||||
express.json(),
|
||||
express.json({limit: '1mb'}),
|
||||
allowCorsUnrestricted,
|
||||
cache,
|
||||
typedEndpoint(path as any, handler as any),
|
||||
@@ -173,6 +689,131 @@ Object.entries(handlers).forEach(([path, handler]) => {
|
||||
}
|
||||
})
|
||||
|
||||
// Internal Endpoints
|
||||
app.post(pathWithPrefix('/internal/send-search-notifications'), async (req, res) => {
|
||||
const apiKey = req.header('x-api-key')
|
||||
if (apiKey !== process.env.COMPASS_API_KEY) {
|
||||
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 {
|
||||
const result = await localSendTestEmail()
|
||||
return res.status(200).json(result)
|
||||
} catch (err) {
|
||||
return res.status(500).json({error: err})
|
||||
}
|
||||
})
|
||||
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) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.status(200).send()
|
||||
@@ -181,7 +822,7 @@ app.use(allowCorsUnrestricted, (req, res) => {
|
||||
.status(404)
|
||||
.set('Content-Type', 'application/json')
|
||||
.json({
|
||||
message: `The requested route '${req.path}' does not exist. Please check your URL for any misspellings or refer to app.ts`,
|
||||
message: `This is the Compass API, but the requested route '${req.path}' does not exist. Please check your URL for any misspellings, the docs at https://api.compassmeet.com, or simply refer to app.ts on GitHub`,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
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 { trackPublicEvent } from 'shared/analytics'
|
||||
import { throwErrorIfNotMod } from 'shared/helpers/auth'
|
||||
import { isAdminId } from 'common/envs/constants'
|
||||
import { log } from 'shared/utils'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { updateUser } from 'shared/supabase/users'
|
||||
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
|
||||
import {isAdminId} from 'common/envs/constants'
|
||||
import {trackPublicEvent} from 'shared/analytics'
|
||||
import {throwErrorIfNotMod} from 'shared/helpers/auth'
|
||||
import {updateUser} from 'shared/supabase/users'
|
||||
import {log} from 'shared/utils'
|
||||
|
||||
export const banUser: APIHandler<'ban-user'> = async (body, auth) => {
|
||||
const { userId, unban } = body
|
||||
const db = createSupabaseDirectClient()
|
||||
const {userId, unban} = body
|
||||
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', {
|
||||
userId,
|
||||
})
|
||||
await updateUser(db, userId, {
|
||||
isBannedFromPosting: !unban,
|
||||
})
|
||||
await updateUser(userId, {isBannedFromPosting: !unban})
|
||||
log('updated user')
|
||||
}
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { APIError, APIHandler } from './helpers/endpoint'
|
||||
import { FieldVal } from 'shared/supabase/utils'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { updatePrivateUser } from 'shared/supabase/users'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {updatePrivateUser} from 'shared/supabase/users'
|
||||
import {FieldVal} from 'shared/supabase/utils'
|
||||
|
||||
export const blockUser: APIHandler<'user/by-id/:id/block'> = async (
|
||||
{ id },
|
||||
auth
|
||||
) => {
|
||||
if (auth.uid === id) throw new APIError(400, 'You cannot block yourself')
|
||||
import {APIErrors, APIHandler} from './helpers/endpoint'
|
||||
|
||||
export const blockUser: APIHandler<'user/by-id/:id/block'> = async ({id}, auth) => {
|
||||
if (auth.uid === id) throw APIErrors.badRequest('You cannot block yourself')
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
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 (
|
||||
{ id },
|
||||
auth
|
||||
) => {
|
||||
export const unblockUser: APIHandler<'user/by-id/:id/unblock'> = async ({id}, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
await pg.tx(async (tx) => {
|
||||
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 +0,0 @@
|
||||
import { groupBy, sortBy } from 'lodash'
|
||||
import { APIError, type APIHandler } from 'api/helpers/endpoint'
|
||||
import { getCompatibilityScore } from 'common/love/compatibility-score'
|
||||
import {
|
||||
getLover,
|
||||
getCompatibilityAnswers,
|
||||
getGenderCompatibleLovers,
|
||||
} from 'shared/love/supabase'
|
||||
import { log } from 'shared/utils'
|
||||
|
||||
export const getCompatibleLoversHandler: APIHandler<
|
||||
'compatible-lovers'
|
||||
> = async (props) => {
|
||||
return getCompatibleLovers(props.userId)
|
||||
}
|
||||
|
||||
export const getCompatibleLovers = async (userId: string) => {
|
||||
const lover = await getLover(userId)
|
||||
|
||||
log('got lover', {
|
||||
id: lover?.id,
|
||||
userId: lover?.user_id,
|
||||
username: lover?.user?.username,
|
||||
})
|
||||
|
||||
if (!lover) throw new APIError(404, 'Lover not found')
|
||||
|
||||
const lovers = await getGenderCompatibleLovers(lover)
|
||||
|
||||
const loverAnswers = await getCompatibilityAnswers([
|
||||
userId,
|
||||
...lovers.map((l) => l.user_id),
|
||||
])
|
||||
log('got lover answers ' + loverAnswers.length)
|
||||
|
||||
const answersByUserId = groupBy(loverAnswers, 'creator_id')
|
||||
const loverCompatibilityScores = Object.fromEntries(
|
||||
lovers.map(
|
||||
(l) =>
|
||||
[
|
||||
l.user_id,
|
||||
getCompatibilityScore(
|
||||
answersByUserId[lover.user_id] ?? [],
|
||||
answersByUserId[l.user_id] ?? []
|
||||
),
|
||||
] as const
|
||||
)
|
||||
)
|
||||
|
||||
const sortedCompatibleLovers = sortBy(
|
||||
lovers,
|
||||
(l) => loverCompatibilityScores[l.user_id].score
|
||||
).reverse()
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
lover,
|
||||
compatibleLovers: sortedCompatibleLovers,
|
||||
loverCompatibilityScores,
|
||||
}
|
||||
}
|
||||
27
backend/api/src/compatible-profiles.ts
Normal file
27
backend/api/src/compatible-profiles.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import {type APIHandler} from 'api/helpers/endpoint'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
export const getCompatibleProfilesHandler: APIHandler<'compatible-profiles'> = async (props) => {
|
||||
return getCompatibleProfiles(props.userId)
|
||||
}
|
||||
|
||||
export const getCompatibleProfiles = async (userId: string) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
const scores = await pg.map(
|
||||
`select *
|
||||
from compatibility_scores
|
||||
where score is not null
|
||||
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,
|
||||
)
|
||||
|
||||
const profileCompatibilityScores = Object.fromEntries(scores)
|
||||
|
||||
// console.log('scores', profileCompatibilityScores)
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
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,39 +1,32 @@
|
||||
import { APIError, APIHandler } from 'api/helpers/endpoint'
|
||||
import { type JSONContent } from '@tiptap/core'
|
||||
import { getPrivateUser, getUser } from 'shared/utils'
|
||||
import {
|
||||
createSupabaseDirectClient,
|
||||
SupabaseDirectClient,
|
||||
} from 'shared/supabase/init'
|
||||
import { getNotificationDestinationsForUser } from 'common/user-notification-preferences'
|
||||
import { Notification } from 'common/notifications'
|
||||
import { insertNotificationToSupabase } from 'shared/supabase/notifications'
|
||||
import { User } from 'common/user'
|
||||
import { richTextToString } from 'common/util/parse'
|
||||
import {type JSONContent} from '@tiptap/core'
|
||||
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
|
||||
import {Notification} from 'common/notifications'
|
||||
import {convertComment} from 'common/supabase/comment'
|
||||
import {type Row} from 'common/supabase/utils'
|
||||
import {User} from 'common/user'
|
||||
import {getNotificationDestinationsForUser} from 'common/user-notification-preferences'
|
||||
import {richTextToString} from 'common/util/parse'
|
||||
import * as crypto from 'crypto'
|
||||
import { sendNewEndorsementEmail } from 'email/functions/helpers'
|
||||
import { type Row } from 'common/supabase/utils'
|
||||
import { broadcastUpdatedComment } from 'shared/websockets/helpers'
|
||||
import { convertComment } from 'common/supabase/comment'
|
||||
import {sendNewEndorsementEmail} from 'email/functions/helpers'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {insertNotificationToSupabase} from 'shared/supabase/notifications'
|
||||
import {getPrivateUser, getUser} from 'shared/utils'
|
||||
import {broadcastUpdatedComment} from 'shared/websockets/helpers'
|
||||
|
||||
export const MAX_COMMENT_JSON_LENGTH = 20000
|
||||
|
||||
export const createComment: APIHandler<'create-comment'> = async (
|
||||
{ userId, content: submittedContent, replyToCommentId },
|
||||
auth
|
||||
{userId, content: submittedContent, replyToCommentId},
|
||||
auth,
|
||||
) => {
|
||||
const { creator, content } = await validateComment(
|
||||
userId,
|
||||
auth.uid,
|
||||
submittedContent
|
||||
)
|
||||
const {creator, content} = await validateComment(userId, auth.uid, submittedContent)
|
||||
|
||||
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 comment = await pg.one<Row<'lover_comments'>>(
|
||||
`insert into lover_comments (user_id, user_name, user_username, user_avatar_url, on_user_id, content, reply_to_comment_id)
|
||||
const comment = await pg.one<Row<'profile_comments'>>(
|
||||
`insert into profile_comments (user_id, user_name, user_username, user_avatar_url, on_user_id, content, reply_to_comment_id)
|
||||
values ($1, $2, $3, $4, $5, $6, $7) returning *`,
|
||||
[
|
||||
creator.id,
|
||||
@@ -43,60 +36,55 @@ export const createComment: APIHandler<'create-comment'> = async (
|
||||
userId,
|
||||
content,
|
||||
replyToCommentId,
|
||||
]
|
||||
],
|
||||
)
|
||||
if (onUser.id !== creator.id)
|
||||
await createNewCommentOnLoverNotification(
|
||||
await createNewCommentOnProfileNotification(
|
||||
onUser,
|
||||
creator,
|
||||
richTextToString(content),
|
||||
comment.id,
|
||||
pg
|
||||
)
|
||||
|
||||
broadcastUpdatedComment(convertComment(comment))
|
||||
|
||||
return { status: 'success' }
|
||||
return {status: 'success'}
|
||||
}
|
||||
|
||||
const validateComment = async (
|
||||
userId: string,
|
||||
creatorId: string,
|
||||
content: JSONContent
|
||||
) => {
|
||||
const validateComment = async (userId: string, creatorId: string, content: JSONContent) => {
|
||||
const creator = await getUser(creatorId)
|
||||
|
||||
if (!creator) throw new APIError(401, 'Your account was not found')
|
||||
if (creator.isBannedFromPosting) throw new APIError(403, 'You are banned')
|
||||
if (!creator) throw APIErrors.unauthorized('Your account was not found')
|
||||
if (creator.isBannedFromPosting) throw APIErrors.forbidden('You are banned')
|
||||
|
||||
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)) {
|
||||
throw new APIError(404, 'User has blocked you')
|
||||
throw APIErrors.notFound('User has blocked you')
|
||||
}
|
||||
|
||||
if (JSON.stringify(content).length > MAX_COMMENT_JSON_LENGTH) {
|
||||
throw new APIError(
|
||||
400,
|
||||
`Comment is too long; should be less than ${MAX_COMMENT_JSON_LENGTH} as a JSON string.`
|
||||
throw APIErrors.badRequest(
|
||||
`Comment is too long; should be less than ${MAX_COMMENT_JSON_LENGTH} as a JSON string.`,
|
||||
)
|
||||
}
|
||||
return { content, creator }
|
||||
return {content, creator}
|
||||
}
|
||||
|
||||
const createNewCommentOnLoverNotification = async (
|
||||
const createNewCommentOnProfileNotification = async (
|
||||
onUser: User,
|
||||
creator: User,
|
||||
sourceText: string,
|
||||
commentId: number,
|
||||
pg: SupabaseDirectClient
|
||||
) => {
|
||||
const privateUser = await getPrivateUser(onUser.id)
|
||||
if (!privateUser) return
|
||||
const id = crypto.randomUUID()
|
||||
const reason = 'new_endorsement'
|
||||
const { sendToBrowser, sendToMobile, sendToEmail } =
|
||||
getNotificationDestinationsForUser(privateUser, reason)
|
||||
const {sendToBrowser, sendToMobile, sendToEmail} = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
reason,
|
||||
)
|
||||
const notification: Notification = {
|
||||
id,
|
||||
userId: privateUser.id,
|
||||
@@ -104,7 +92,7 @@ const createNewCommentOnLoverNotification = async (
|
||||
createdTime: Date.now(),
|
||||
isSeen: false,
|
||||
sourceId: commentId.toString(),
|
||||
sourceType: 'comment_on_lover',
|
||||
sourceType: 'comment_on_profile',
|
||||
sourceUpdateType: 'created',
|
||||
sourceUserName: creator.name,
|
||||
sourceUserUsername: creator.username,
|
||||
@@ -113,7 +101,7 @@ const createNewCommentOnLoverNotification = async (
|
||||
sourceSlug: onUser.username,
|
||||
}
|
||||
if (sendToBrowser) {
|
||||
await insertNotificationToSupabase(notification, pg)
|
||||
await insertNotificationToSupabase(notification)
|
||||
}
|
||||
if (sendToMobile) {
|
||||
// await createPushNotification(
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { getUser } from 'shared/utils'
|
||||
import { APIHandler, APIError } from './helpers/endpoint'
|
||||
import { insert } from 'shared/supabase/utils'
|
||||
import { tryCatch } from 'common/util/try-catch'
|
||||
import {tryCatch} from 'common/util/try-catch'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {insert} from 'shared/supabase/utils'
|
||||
import {getUser} from 'shared/utils'
|
||||
|
||||
export const createCompatibilityQuestion: APIHandler<
|
||||
'create-compatibility-question'
|
||||
> = async ({ question, options }, auth) => {
|
||||
import {APIErrors, APIHandler} from './helpers/endpoint'
|
||||
|
||||
export const createCompatibilityQuestion: APIHandler<'create-compatibility-question'> = async (
|
||||
{question, options},
|
||||
auth,
|
||||
) => {
|
||||
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 { data, error } = await tryCatch(
|
||||
insert(pg, 'love_questions', {
|
||||
const {data, error} = await tryCatch(
|
||||
insert(pg, 'compatibility_prompts', {
|
||||
creator_id: creator.id,
|
||||
question,
|
||||
answer_type: 'compatibility_multiple_choice',
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { APIError, APIHandler } from 'api/helpers/endpoint'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { log, getUser } from 'shared/utils'
|
||||
import { HOUR_MS } from 'common/util/time'
|
||||
import { removePinnedUrlFromPhotoUrls } from 'shared/love/parse-photos'
|
||||
import { track } from 'shared/analytics'
|
||||
import { updateUser } from 'shared/supabase/users'
|
||||
import { tryCatch } from 'common/util/try-catch'
|
||||
import { insert } from 'shared/supabase/utils'
|
||||
|
||||
export const createLover: APIHandler<'create-lover'> = async (body, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const { data: existingUser } = await tryCatch(
|
||||
pg.oneOrNone<{ id: string }>('select id from lovers where user_id = $1', [
|
||||
auth.uid,
|
||||
])
|
||||
)
|
||||
if (existingUser) {
|
||||
throw new APIError(400, 'User already exists')
|
||||
}
|
||||
|
||||
await removePinnedUrlFromPhotoUrls(body)
|
||||
const user = await getUser(auth.uid)
|
||||
if (!user) throw new APIError(401, 'Your account was not found')
|
||||
if (user.createdTime > Date.now() - HOUR_MS) {
|
||||
// If they just signed up, set their avatar to be their pinned photo
|
||||
updateUser(pg, auth.uid, { avatarUrl: body.pinned_url })
|
||||
}
|
||||
|
||||
console.log('body', body)
|
||||
|
||||
const { data, error } = await tryCatch(
|
||||
insert(pg, 'lovers', { user_id: auth.uid, ...body })
|
||||
)
|
||||
|
||||
if (error) {
|
||||
log.error('Error creating user: ' + error.message)
|
||||
throw new APIError(500, 'Error creating user')
|
||||
}
|
||||
|
||||
log('Created user', data)
|
||||
await track(user.id, 'create lover', { username: user.username })
|
||||
|
||||
return data
|
||||
}
|
||||
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 { filterDefined } from 'common/util/array'
|
||||
import { uniq } from 'lodash'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { addUsersToPrivateMessageChannel } from 'api/junk-drawer/private-messages'
|
||||
import { getPrivateUser, getUser } from 'shared/utils'
|
||||
import {getConnectionInterests} from 'api/get-connection-interests'
|
||||
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
|
||||
import {addUsersToPrivateMessageChannel} from 'api/helpers/private-messages'
|
||||
import {filterDefined} from 'common/util/array'
|
||||
import * as admin from 'firebase-admin'
|
||||
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<
|
||||
'create-private-user-message-channel'
|
||||
> = 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 pg = createSupabaseDirectClient()
|
||||
const creatorId = auth.uid
|
||||
|
||||
const creator = await getUser(creatorId)
|
||||
if (!creator) throw new APIError(401, 'Your account was not found')
|
||||
if (creator.isBannedFromPosting) throw new APIError(403, 'You are banned')
|
||||
const toPrivateUsers = filterDefined(
|
||||
await Promise.all(userIds.map((id) => getPrivateUser(id)))
|
||||
)
|
||||
if (!creator) throw APIErrors.unauthorized('Your account was not found')
|
||||
if (creator.isBannedFromPosting) throw APIErrors.forbidden('You are banned')
|
||||
const toPrivateUsers = filterDefined(await Promise.all(userIds.map((id) => getPrivateUser(id))))
|
||||
|
||||
if (toPrivateUsers.length !== userIds.length)
|
||||
throw new APIError(
|
||||
404,
|
||||
throw APIErrors.notFound(
|
||||
`Private user ${userIds.find(
|
||||
(uid) => !toPrivateUsers.map((p) => p.id).includes(uid)
|
||||
)} not found`
|
||||
(uid) => !toPrivateUsers.map((p: any) => p.id).includes(uid),
|
||||
)} not found`,
|
||||
)
|
||||
|
||||
if (
|
||||
toPrivateUsers.some((user) =>
|
||||
user.blockedUserIds.some((blockedId) => userIds.includes(blockedId))
|
||||
toPrivateUsers.some((user: any) =>
|
||||
user.blockedUserIds.some((blockedId: string) => userIds.includes(blockedId)),
|
||||
)
|
||||
) {
|
||||
throw new APIError(
|
||||
403,
|
||||
'One of the users has blocked another user in the list'
|
||||
)
|
||||
throw APIErrors.forbidden('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(
|
||||
`
|
||||
select channel_id from private_user_message_channel_members
|
||||
group by channel_id
|
||||
having array_agg(user_id::text) @> array[$1]::text[]
|
||||
and array_agg(user_id::text) <@ array[$1]::text[]
|
||||
`,
|
||||
[userIds]
|
||||
select channel_id
|
||||
from private_user_message_channel_members
|
||||
group by channel_id
|
||||
having array_agg(user_id::text) @> array [$1]::text[]
|
||||
and array_agg(user_id::text) <@ array [$1]::text[]
|
||||
`,
|
||||
[userIds],
|
||||
)
|
||||
if (currentChannel)
|
||||
return {
|
||||
@@ -55,17 +74,19 @@ export const createPrivateUserMessageChannel: APIHandler<
|
||||
}
|
||||
|
||||
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(
|
||||
`insert into private_user_message_channel_members (channel_id, user_id, role, status)
|
||||
values ($1, $2, 'creator', 'joined')
|
||||
`,
|
||||
[channel.id, creatorId]
|
||||
values ($1, $2, 'creator', 'joined')
|
||||
`,
|
||||
[channel.id, creatorId],
|
||||
)
|
||||
|
||||
const memberIds = userIds.filter((id) => id !== creatorId)
|
||||
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 { getUser } from 'shared/utils'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { MAX_COMMENT_JSON_LENGTH } from 'api/create-comment'
|
||||
import { createPrivateUserMessageMain } from 'api/junk-drawer/private-messages'
|
||||
import {MAX_COMMENT_JSON_LENGTH} from 'api/create-comment'
|
||||
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
|
||||
import {createPrivateUserMessageMain} from 'api/helpers/private-messages'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {getUser} from 'shared/utils'
|
||||
|
||||
export const createPrivateUserMessage: APIHandler<
|
||||
'create-private-user-message'
|
||||
> = async (body, auth) => {
|
||||
const { content, channelId } = body
|
||||
export const createPrivateUserMessage: APIHandler<'create-private-user-message'> = async (
|
||||
body,
|
||||
auth,
|
||||
) => {
|
||||
const {content, channelId} = body
|
||||
if (JSON.stringify(content).length > MAX_COMMENT_JSON_LENGTH) {
|
||||
throw new APIError(
|
||||
400,
|
||||
`Message JSON should be less than ${MAX_COMMENT_JSON_LENGTH}`
|
||||
)
|
||||
throw APIErrors.badRequest(`Message JSON should be less than ${MAX_COMMENT_JSON_LENGTH}`)
|
||||
}
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const creator = await getUser(auth.uid)
|
||||
if (!creator) throw new APIError(401, 'Your account was not found')
|
||||
if (creator.isBannedFromPosting) throw new APIError(403, 'You are banned')
|
||||
return await createPrivateUserMessageMain(
|
||||
creator,
|
||||
channelId,
|
||||
content,
|
||||
pg,
|
||||
'private'
|
||||
)
|
||||
if (!creator) throw APIErrors.unauthorized('Your account was not found')
|
||||
if (creator.isBannedFromPosting) throw APIErrors.forbidden('You are banned')
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
return await createPrivateUserMessageMain(creator, channelId, content, pg, 'private')
|
||||
}
|
||||
|
||||
90
backend/api/src/create-profile.ts
Normal file
90
backend/api/src/create-profile.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
|
||||
import {sendDiscordMessage} from 'common/discord/core'
|
||||
import {debug} from 'common/logger'
|
||||
import {jsonToMarkdown} from 'common/md'
|
||||
import {trimStrings} from 'common/parsing'
|
||||
import {HOUR_MS, MINUTE_MS, sleep} from 'common/util/time'
|
||||
import {tryCatch} from 'common/util/try-catch'
|
||||
import {track} from 'shared/analytics'
|
||||
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) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const {data: existingProfile} = await tryCatch(
|
||||
pg.oneOrNone<{id: string}>('select id from profiles where user_id = $1', [auth.uid]),
|
||||
)
|
||||
if (existingProfile) {
|
||||
throw APIErrors.badRequest('Profile already exists')
|
||||
}
|
||||
|
||||
await removePinnedUrlFromPhotoUrls(body)
|
||||
trimStrings(body)
|
||||
|
||||
const user = await getUser(auth.uid)
|
||||
if (!user) throw APIErrors.unauthorized('Your account was not found')
|
||||
if (user.createdTime > Date.now() - HOUR_MS) {
|
||||
// If they just signed up, set their avatar to be their pinned photo
|
||||
updateUserData(pg, auth.uid, {avatarUrl: body.pinned_url || undefined})
|
||||
}
|
||||
|
||||
debug('body', body)
|
||||
|
||||
const {data, error} = await tryCatch(insert(pg, 'profiles', {user_id: auth.uid, ...body}))
|
||||
|
||||
if (error) {
|
||||
log.error('Error creating user: ' + error.message)
|
||||
throw APIErrors.internalServerError('Error creating user')
|
||||
}
|
||||
|
||||
log('Created profile', 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',
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user