mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-04-04 14:53:33 -04:00
Compare commits
786 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
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.
|
||||||
29
.env.example
29
.env.example
@@ -1,20 +1,13 @@
|
|||||||
# Rename this file to `.env` and fill in the values.
|
|
||||||
|
# use firebase emulator for running e2e tests
|
||||||
|
NEXT_PUBLIC_FIREBASE_EMULATOR=false
|
||||||
|
FIREBASE_AUTH_EMULATOR_HOST=127.0.0.1:9099
|
||||||
|
FIREBASE_STORAGE_EMULATOR_HOST=127.0.0.1:9199
|
||||||
|
|
||||||
# You already have access to basic local functionality (UI, authentication, database read access).
|
# You already have access to basic local functionality (UI, authentication, database read access).
|
||||||
|
|
||||||
# Optional variables for the backend server functionality (modifying user data, etc.)
|
# openssl enc -aes-256-cbc -salt -pbkdf2 -iter 100000 -in backend/shared/src/googleApplicationCredentials-dev.json -out secrets/googleApplicationCredentials-dev.json.enc
|
||||||
|
GOOGLE_CREDENTIALS_ENC_PWD=nP7s3274uzOG4c2t
|
||||||
# For database write access (dev).
|
|
||||||
# A 16-character password with digits and letters.
|
|
||||||
SUPABASE_DB_PASSWORD=09wATRREfAzyL5pc
|
|
||||||
|
|
||||||
# For Firebase access.
|
|
||||||
# Open a GitHub issue with your contribution ideas and an admin will give you the key.
|
|
||||||
# TODO: find a way to give anyone moderate access to dev firebase.
|
|
||||||
GOOGLE_APPLICATION_CREDENTIALS_DEV="[...].json"
|
|
||||||
|
|
||||||
# The URL where your local backend server is running.
|
|
||||||
# You can change the port if needed.
|
|
||||||
NEXT_PUBLIC_API_URL=localhost:8088
|
|
||||||
|
|
||||||
|
|
||||||
# Optional variables for full local functionality
|
# Optional variables for full local functionality
|
||||||
@@ -23,10 +16,6 @@ NEXT_PUBLIC_API_URL=localhost:8088
|
|||||||
# Create a free account at https://rapidapi.com/wirefreethought/api/geodb-cities and get an API key.
|
# Create a free account at https://rapidapi.com/wirefreethought/api/geodb-cities and get an API key.
|
||||||
GEODB_API_KEY=
|
GEODB_API_KEY=
|
||||||
|
|
||||||
# For analytics like page views, user actions, feature usage, etc.
|
|
||||||
# Create a free account at https://posthog.com and get a project API key. Should start with "phc_".
|
|
||||||
POSTHOG_KEY=
|
|
||||||
|
|
||||||
# For sending emails (e.g. for user sign up, password reset, notifications, etc.).
|
# For sending emails (e.g. for user sign up, password reset, notifications, etc.).
|
||||||
# Create a free account at https://resend.com and get an API key. Should start with "re_".
|
# Create a free account at https://resend.com and get an API key. Should start with "re_".
|
||||||
RESEND_API_KEY=
|
RESEND_KEY=
|
||||||
|
|||||||
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']
|
||||||
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
|
||||||
15
.github/ISSUE_TEMPLATE/other.yml
vendored
Normal file
15
.github/ISSUE_TEMPLATE/other.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
name: Other
|
||||||
|
description: Any other question or issue
|
||||||
|
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
attributes:
|
||||||
|
label: Issue
|
||||||
|
description: >
|
||||||
|
A clear and concise description of the question or issue
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- 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 -->
|
||||||
|
|
||||||
96
.github/workflows/cd-api.yml
vendored
Normal file
96
.github/workflows/cd-api.yml
vendored
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
name: CD API
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [ main, master ]
|
||||||
|
paths:
|
||||||
|
- "backend/api/package.json"
|
||||||
|
- ".github/workflows/cd-api.yml"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
name: Deploy
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # we need full history for git log
|
||||||
|
|
||||||
|
- name: Install jq
|
||||||
|
run: sudo apt-get install -y jq
|
||||||
|
|
||||||
|
- 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: |
|
||||||
|
# Get previous commit’s package.json (if it existed)
|
||||||
|
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: Check version change
|
||||||
|
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 "changed=false" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "changed=true" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
if: steps.check.outputs.changed == 'true'
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '22'
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
if: steps.check.outputs.changed == 'true'
|
||||||
|
run: yarn install
|
||||||
|
|
||||||
|
- name: Authenticate to GCP
|
||||||
|
if: steps.check.outputs.changed == 'true'
|
||||||
|
uses: google-github-actions/auth@v2
|
||||||
|
with:
|
||||||
|
credentials_json: ${{ secrets.GCP_SA_KEY }}
|
||||||
|
|
||||||
|
- name: Install gcloud CLI
|
||||||
|
if: steps.check.outputs.changed == 'true'
|
||||||
|
uses: google-github-actions/setup-gcloud@v2
|
||||||
|
with:
|
||||||
|
project_id: compass-130ba
|
||||||
|
|
||||||
|
- name: Configure Docker for Artifact Registry
|
||||||
|
if: steps.check.outputs.changed == 'true'
|
||||||
|
run: |
|
||||||
|
gcloud auth configure-docker us-west1-docker.pkg.dev --quiet
|
||||||
|
|
||||||
|
- name: Install Tofu (Terraform)
|
||||||
|
if: steps.check.outputs.changed == 'true'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y wget unzip
|
||||||
|
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 || exit 1
|
||||||
|
tofu init
|
||||||
|
|
||||||
|
- name: Run deploy script
|
||||||
|
if: steps.check.outputs.changed == 'true'
|
||||||
|
run: |
|
||||||
|
chmod +x backend/api/deploy-api.sh
|
||||||
|
backend/api/deploy-api.sh
|
||||||
3
.github/workflows/cd.yml
vendored
3
.github/workflows/cd.yml
vendored
@@ -6,6 +6,9 @@ name: CD
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [ main, master ]
|
branches: [ main, master ]
|
||||||
|
paths:
|
||||||
|
- "package.json"
|
||||||
|
- ".github/workflows/cd.yml"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
|
|||||||
57
.github/workflows/ci.yml
vendored
57
.github/workflows/ci.yml
vendored
@@ -10,7 +10,7 @@ on:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
ci:
|
||||||
name: All
|
name: Tests
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -32,29 +32,44 @@ jobs:
|
|||||||
run: npm run lint
|
run: npm run lint
|
||||||
|
|
||||||
- name: Run Jest tests
|
- name: Run Jest tests
|
||||||
run: npm run test tests/jest
|
env:
|
||||||
|
NEXT_PUBLIC_FIREBASE_ENV: DEV
|
||||||
# - name: Build app
|
run: |
|
||||||
# env:
|
yarn test:coverage
|
||||||
# DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
# npm install -g lcov-result-merger
|
||||||
# run: npm run build
|
# mkdir coverage
|
||||||
|
# lcov-result-merger \
|
||||||
|
# "backend/api/coverage/lcov.info" \
|
||||||
|
# "backend/shared/coverage/lcov.info" \
|
||||||
|
# "backend/email/coverage/lcov.info" \
|
||||||
|
# "common/coverage/lcov.info" \
|
||||||
|
# "web/coverage/lcov.info" \
|
||||||
|
# > coverage/lcov.info
|
||||||
|
|
||||||
# Optional: Playwright E2E tests
|
# Optional: Playwright E2E tests
|
||||||
- name: Install Playwright deps
|
- name: Install Playwright deps
|
||||||
run: npx playwright install --with-deps
|
run: |
|
||||||
# npm install @playwright/test
|
npx playwright install chromium
|
||||||
# npx playwright install
|
# npx playwright install --with-deps
|
||||||
|
# npm install @playwright/test
|
||||||
|
|
||||||
- name: Run E2E tests
|
- name: Run E2E tests
|
||||||
env:
|
|
||||||
NEXT_PUBLIC_API_URL: localhost:8088
|
|
||||||
NEXT_PUBLIC_FIREBASE_ENV: PROD
|
|
||||||
NEXT_PUBLIC_FIREBASE_API_KEY: ${{ secrets.NEXT_PUBLIC_FIREBASE_API_KEY }}
|
|
||||||
NEXT_PUBLIC_SUPABASE_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_KEY }}
|
|
||||||
run: |
|
run: |
|
||||||
yarn --cwd=web serve &
|
chmod +x scripts/e2e.sh
|
||||||
npx wait-on http://localhost:3000
|
./scripts/e2e.sh
|
||||||
npx playwright test tests/playwright
|
|
||||||
SERVER_PID=$(fuser -k 3000/tcp)
|
- name: Upload coverage to Codecov
|
||||||
echo $SERVER_PID
|
uses: codecov/codecov-action@v5
|
||||||
kill $SERVER_PID
|
with:
|
||||||
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
|
files: |
|
||||||
|
backend/api/coverage/lcov.info
|
||||||
|
backend/shared/coverage/lcov.info
|
||||||
|
backend/email/coverage/lcov.info
|
||||||
|
common/coverage/lcov.info
|
||||||
|
web/coverage/lcov.info
|
||||||
|
flags: unit
|
||||||
|
fail_ci_if_error: true
|
||||||
|
slug: CompassConnections/Compass
|
||||||
|
env:
|
||||||
|
CI: true
|
||||||
|
|||||||
24
.gitignore
vendored
24
.gitignore
vendored
@@ -13,6 +13,10 @@
|
|||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
|
||||||
|
# Playwright
|
||||||
|
/tests/reports/playwright-report
|
||||||
|
/tests/e2e/web/.auth/
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
/out/
|
/out/
|
||||||
@@ -61,15 +65,17 @@ email-preview
|
|||||||
*.last-run.json
|
*.last-run.json
|
||||||
|
|
||||||
*lock.hcl
|
*lock.hcl
|
||||||
/web/pages/test.tsx
|
/web/pages/_test.tsx
|
||||||
|
|
||||||
*.png
|
*.png
|
||||||
*.jpg
|
*.jpg
|
||||||
*.jpeg
|
*.jpeg
|
||||||
*.gif
|
*.gif
|
||||||
*.svg
|
*.svg
|
||||||
|
*.ico
|
||||||
*.mp4
|
*.mp4
|
||||||
*.mov
|
*.mov
|
||||||
|
*.webp
|
||||||
*.avi
|
*.avi
|
||||||
*.wmv
|
*.wmv
|
||||||
*.mp3
|
*.mp3
|
||||||
@@ -79,3 +85,19 @@ email-preview
|
|||||||
*.zip
|
*.zip
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
*.rar
|
*.rar
|
||||||
|
/favicon_color.ico
|
||||||
|
/backend/shared/src/googleApplicationCredentials-dev.json
|
||||||
|
*.tfstate
|
||||||
|
*.tfstate.backup
|
||||||
|
*.terraform
|
||||||
|
/backups/firebase/auth/data/
|
||||||
|
/backups/firebase/storage/data/
|
||||||
|
|
||||||
|
android/app/release*
|
||||||
|
icons/
|
||||||
|
*.bak
|
||||||
|
|
||||||
|
test-results
|
||||||
|
/.nyc_output/
|
||||||
|
|
||||||
|
**/coverage
|
||||||
|
|||||||
32
.vscode/launch.json
vendored
Normal file
32
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
// 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 Jest Tests",
|
||||||
|
"type": "node",
|
||||||
|
"request": "launch",
|
||||||
|
"runtimeArgs": [
|
||||||
|
"--inspect-brk",
|
||||||
|
"${workspaceRoot}/node_modules/.bin/jest",
|
||||||
|
"--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"
|
||||||
|
// ]
|
||||||
|
// }
|
||||||
|
]
|
||||||
|
}
|
||||||
422
.windsurf/rules/compass.md
Normal file
422
.windsurf/rules/compass.md
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
---
|
||||||
|
trigger: always_on
|
||||||
|
description:
|
||||||
|
globs:
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
121
README.md
121
README.md
@@ -1,13 +1,14 @@
|
|||||||
|
|
||||||
[](https://github.com/CompassConnections/Compass/actions/workflows/ci.yml)
|

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

|
[](https://github.com/CompassConnections/Compass/actions/workflows/cd-api.yml)
|
||||||
|
[](https://github.com/CompassConnections/Compass/actions/workflows/ci.yml)
|
||||||
|
[](https://codecov.io/gh/CompassConnections/Compass)
|
||||||
|
[](https://www.compassmeet.com/stats)
|
||||||
|
|
||||||
# Compass
|
# Compass
|
||||||
|
|
||||||
This repository contains the source code for [Compass](https://compassmeet.com) — an open platform for forming deep, authentic 1-on-1 connections with clarity and efficiency.
|
This repository contains the source code for [Compass](https://compassmeet.com) — a transparent platform for forming deep, authentic 1-on-1 connections with clarity and efficiency.
|
||||||
|
|
||||||
**We can’t do this alone.** Whatever your skills—coding, design, writing, moderation, marketing, or even small donations—you can make a real difference. [Contribute](https://www.compassmeet.com/support) in any way you can and help our community thrive!
|
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
@@ -21,11 +22,28 @@ This repository contains the source code for [Compass](https://compassmeet.com)
|
|||||||
You can find a lot of interesting info in the [About page](https://www.compassmeet.com/about) and the [FAQ](https://www.compassmeet.com/faq) as well.
|
You can find a lot of interesting info in the [About page](https://www.compassmeet.com/about) and 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).
|
A detailed description of the early vision is also available in this [blog post](https://martinbraquet.com/meeting-rational) (you can disregard the parts about rationality, as Compass shifted to a more general audience).
|
||||||
|
|
||||||
|
**We can’t do this alone.** Whatever your skills—coding, design, writing, moderation, marketing, or even small donations—you can make a real difference. [Contribute](https://www.compassmeet.com/support) in any way you can and help our community thrive!
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## To Do
|
## To Do
|
||||||
|
|
||||||
No contribution is too small—whether it’s changing a color, resizing a button, tweaking a font, or improving wording. Bigger contributions like adding new profile fields, building modules, or improving onboarding are equally welcome. The goal is to make the platform better step by step, and every improvement counts. If you see something that could be clearer, smoother, or more engaging, **please jump in**!
|
No contribution is too small—whether it’s changing a color, resizing a button, tweaking a font, or improving wording. Bigger contributions like adding new profile fields, building modules, or improving onboarding are equally welcome. The goal is to make the platform better step by step, and every improvement counts. If you see something that could be clearer, smoother, or more engaging, **please jump in**!
|
||||||
|
|
||||||
Here are some examples of things that would be very useful. If you want to help but don’t know where to start, just ask us on [Discord](https://discord.gg/8Vd7jzqjun).
|
The complete, official list of tasks is available [here on ClickUp](https://sharing.clickup.com/90181043445/l/h/6-901810339879-1/bbfd32f4f4bf64b). If you are working on one task, just assign it to yourself and move its status to "in progress". If there is also a GitHub issue for that task, assign it to yourself as well.
|
||||||
|
|
||||||
|
To have edit access to the ClickUp workspace, you need an admin to manually give you permission (one time thing). To do so, use your preferred option:
|
||||||
|
- Ask or DM an admin on [Discord](https://discord.gg/8Vd7jzqjun)
|
||||||
|
- Email hello@compassmeet.com
|
||||||
|
- Raise an issue on GitHub
|
||||||
|
|
||||||
|
If you want to add tasks without creating an account, you can simply email
|
||||||
|
```
|
||||||
|
a.t.901810339879.u-276866260.b847aba1-2709-4f17-b4dc-565a6967c234@tasks.clickup.com
|
||||||
|
```
|
||||||
|
Put the task title in the email subject and the task description in the email content.
|
||||||
|
|
||||||
|
Here is a tailored selection of things that would be very useful. If you want to help but don’t know where to start, just ask us on [Discord](https://discord.gg/8Vd7jzqjun).
|
||||||
|
|
||||||
- [x] Authentication (user/password and Google Sign In)
|
- [x] Authentication (user/password and Google Sign In)
|
||||||
- [x] Set up PostgreSQL in Production with supabase
|
- [x] Set up PostgreSQL in Production with supabase
|
||||||
@@ -36,7 +54,9 @@ Here are some examples of things that would be very useful. If you want to help
|
|||||||
- [x] Search through most profile variables
|
- [x] Search through most profile variables
|
||||||
- [x] Set up chat / direct messaging
|
- [x] Set up chat / direct messaging
|
||||||
- [x] Set up domain name (compassmeet.com)
|
- [x] Set up domain name (compassmeet.com)
|
||||||
- [ ] Add mobile app (React Native on Android and iOS)
|
- [ ] Cover more than 90% with tests (unit, integration, e2e)
|
||||||
|
- [x] Add Android mobile app
|
||||||
|
- [ ] Add iOS mobile app
|
||||||
- [ ] Add better onboarding (tooltips, modals, etc.)
|
- [ ] Add better onboarding (tooltips, modals, etc.)
|
||||||
- [ ] Add modules to learn more about each other (personality test, conflict style, love languages, etc.)
|
- [ ] Add modules to learn more about each other (personality test, conflict style, love languages, etc.)
|
||||||
- [ ] Add modules to improve interpersonal skills (active listening, nonviolent communication, etc.)
|
- [ ] Add modules to improve interpersonal skills (active listening, nonviolent communication, etc.)
|
||||||
@@ -49,22 +69,23 @@ Everything is open to anyone for collaboration, but the following ones are parti
|
|||||||
|
|
||||||
- [x] Clean up learn more page
|
- [x] Clean up learn more page
|
||||||
- [x] Add dark theme
|
- [x] Add dark theme
|
||||||
- [ ] Add profile features (intellectual interests, cause areas, personality type, conflict style, etc.)
|
- [x] Add profile fields (intellectual interests, cause areas, personality type, etc.)
|
||||||
- [ ] Add filters to search through remaining profile features (politics, religion, education level, etc.)
|
- [ ] Add profile fields: conflict style
|
||||||
- [ ] Cover with tests (very important, just the test template and framework are ready)
|
- [ ] Add profile fields: timezone
|
||||||
|
- [x] Add filters to search through remaining profile fields (politics, religion, education level, etc.)
|
||||||
- [ ] Make the app more user-friendly and appealing (UI/UX)
|
- [ ] Make the app more user-friendly and appealing (UI/UX)
|
||||||
- [ ] Clean up terms and conditions (convert to Markdown)
|
- [ ] Clean up terms and conditions (convert to Markdown)
|
||||||
- [ ] Clean up privacy notice (convert to Markdown)
|
- [ ] Clean up privacy notice (convert to Markdown)
|
||||||
- [ ] Add other authentication methods (GitHub, Facebook, Apple, phone, etc.)
|
- [ ] Add other authentication methods (GitHub, Facebook, Apple, phone, etc.)
|
||||||
- [ ] Add email verification
|
- [x] Add email verification
|
||||||
- [ ] Add password reset
|
- [x] Add password reset
|
||||||
- [ ] Add automated welcome email
|
- [x] Add automated welcome email
|
||||||
- [ ] Security audit and penetration testing
|
- [ ] Security audit and penetration testing
|
||||||
- [ ] Make `deploy-api.sh` run automatically on push to `main` branch
|
- [x] Make `deploy-api.sh` run automatically on push to `main` branch
|
||||||
- [ ] Create settings page (change email, password, delete account, etc.)
|
- [x] Create settings page (change email, password, delete account, etc.)
|
||||||
- [ ] Improve [financials](web/public/md/financials.md) page (donor / acknowledgments, etc.)
|
- [ ] Improve [financials](web/public/md/financials.md) page (donor / acknowledgments, etc.)
|
||||||
- [ ] Improve loading sign (e.g., animation of a compass moving around)
|
- [x] Improve loading sign (e.g., animation of a compass moving around)
|
||||||
- [ ] Show compatibility score in profile page
|
- [x] Show compatibility score in profile page
|
||||||
|
|
||||||
## Implementation
|
## Implementation
|
||||||
|
|
||||||
@@ -87,41 +108,23 @@ git clone https://github.com/<your-username>/Compass.git
|
|||||||
cd Compass
|
cd Compass
|
||||||
```
|
```
|
||||||
|
|
||||||
Install `opentofu`, `docker`, and `yarn`. Try running this on Linux or macOS for a faster install:
|
Install `yarn` (if not already installed):
|
||||||
```bash
|
```bash
|
||||||
./setup.sh
|
npm install --global yarn
|
||||||
```
|
```
|
||||||
If it doesn't work, you can install them manually (google how to install `opentofu`, `docker`, and `yarn` for your OS).
|
|
||||||
|
|
||||||
Then, install the dependencies for this project:
|
Then, install the dependencies for this project:
|
||||||
```bash
|
```bash
|
||||||
yarn install
|
yarn install
|
||||||
```
|
```
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
|
|
||||||
We can't make the following information public, for security and privacy reasons:
|
|
||||||
- Database, otherwise anyone could access all the user data (including private messages)
|
|
||||||
- Firebase, otherwise anyone could remove users or modify the media files
|
|
||||||
- Email, analytics, and location services, otherwise anyone could use our paid plan
|
|
||||||
|
|
||||||
We separate all those services between production and local development, so that you can code freely without impacting the functioning of the platform.
|
|
||||||
Contributors should use the default keys for local development. Production uses a separate environment with stricter rules and private keys that are not shared.
|
|
||||||
|
|
||||||
Most of the code will work out of the box. All you need to do is creating an `.env` file as a copy of `.env.example`:
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
If you do need one of the few remaining services, you need to store your own secrets as environment variables. To do so, simply open `.env` and fill in the variables according to the instructions in the file.
|
|
||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
|
|
||||||
Make sure the tests pass:
|
Make sure the tests pass:
|
||||||
```bash
|
```bash
|
||||||
yarn test tests/jest/
|
yarn test
|
||||||
```
|
```
|
||||||
TODO: make `yarn test` run all the tests, not just the ones in `tests/jest/`.
|
If they don't and you can't find out why, simply raise an issue! Sometimes it's something on our end that we overlooked.
|
||||||
|
|
||||||
### Running the Development Server
|
### Running the Development Server
|
||||||
|
|
||||||
@@ -132,11 +135,33 @@ yarn dev
|
|||||||
|
|
||||||
Once the server is running, visit http://localhost:3000 to start using the app. You can sign up and visit the profiles; you should see a few synthetic profiles.
|
Once the server is running, visit http://localhost:3000 to start using the app. You can sign up and visit the profiles; you should see a few synthetic profiles.
|
||||||
|
|
||||||
|
Note: it's normal if page loading locally is much slower than the deployed version. It can take up to 10 seconds, it would be great to improve that though!
|
||||||
|
|
||||||
### Contributing
|
### Contributing
|
||||||
|
|
||||||
Now you can start contributing by making changes and submitting pull requests!
|
Now you can start contributing by making changes and submitting pull requests!
|
||||||
|
|
||||||
See [development.md](docs/development.md) for additional instructions, such as adding new profile features.
|
We recommend using a good code editor (VSCode, WebStorm, Cursor, etc.) with Typescript support and a good AI assistant (GitHub Copilot, etc.) to make your life easier. To debug, you can use the browser developer tools (F12), specifically:
|
||||||
|
- Components tab to see the React component tree and props (you need to install the [React Developer Tools](https://react.dev/learn/react-developer-tools) extension)
|
||||||
|
- Console tab for errors and logs
|
||||||
|
- Network tab to see the requests and responses
|
||||||
|
- Storage tab to see cookies and local storage
|
||||||
|
|
||||||
|
You can also add `console.log()` statements in the code.
|
||||||
|
|
||||||
|
If you are new to Typescript or the open-source space, you could start with small changes, such as tweaking some web components or improving wording in some pages. You can find those files in `web/public/md/`.
|
||||||
|
|
||||||
|
##### Resources
|
||||||
|
|
||||||
|
There is a lof 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.
|
||||||
|
- [web](web) for the web.
|
||||||
|
- [backend/api](backend/api) for the backend API.
|
||||||
|
- [android](android) for the Android app.
|
||||||
|
|
||||||
|
There are a lot of useful scripts you can use in the [scripts](scripts) folder.
|
||||||
|
|
||||||
### Submission
|
### Submission
|
||||||
|
|
||||||
@@ -163,5 +188,19 @@ git push origin <branch-name>
|
|||||||
|
|
||||||
Finally, open a Pull Request on GitHub from your `fork/<branch-name>` → `CompassConnections/Compass` main branch.
|
Finally, open a Pull Request on GitHub from your `fork/<branch-name>` → `CompassConnections/Compass` main branch.
|
||||||
|
|
||||||
|
### Environment Variables
|
||||||
|
|
||||||
|
Almost all the features will work out of the box, so you can skip this step and come back later if you need to test the following services: email, geolocation.
|
||||||
|
|
||||||
|
We can't make the following information public, for security and privacy reasons:
|
||||||
|
- Database, otherwise anyone could access all the user data (including private messages)
|
||||||
|
- Firebase, otherwise anyone could remove users or modify the media files
|
||||||
|
- Email, analytics, and location services, otherwise anyone could use the service plans Compass paid for and run up the bill.
|
||||||
|
|
||||||
|
That's why we separate all those services between production and development environments, so that you can code freely without impacting the functioning of the deployed platform.
|
||||||
|
Contributors should use the default keys for local development. Production uses a separate environment with stricter rules and private keys that are not shared.
|
||||||
|
|
||||||
|
If you do need one of the few remaining services, you need to set them up and store your own secrets as environment variables. To do so, simply open `.env` and fill in the variables according to the instructions in the file.
|
||||||
|
|
||||||
## Acknowledgements
|
## Acknowledgements
|
||||||
This project is built on top of [manifold.love](https://github.com/sipec/polylove), an open-source dating platform licensed under the MIT License. We greatly appreciate their work and contributions to open-source, which have significantly aided in the development of some core features such as direct messaging, prompts, and email notifications. We invite the community to explore and contribute to other open-source projects like manifold.love as well, especially if you're interested in functionalities that deviate from Compass' ideals of deep, intentional connections.
|
This project is built on top of [manifold.love](https://github.com/sipec/polylove), an open-source dating platform licensed under the MIT License. We greatly appreciate their work and contributions to open-source, which have significantly aided in the development of some core features such as direct messaging, prompts, and email notifications. We invite the community to explore and contribute to other open-source projects like manifold.love as well, especially if you're interested in functionalities that deviate from Compass' ideals of deep, intentional connections.
|
||||||
|
|||||||
@@ -8,5 +8,5 @@
|
|||||||
|
|
||||||
## Reporting a Vulnerability
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
Contact the development team at compass.meet.info@gmail.com to report a vulnerability. You should receive updates within a week.
|
Contact the development team at hello@compassmeet.com to report a vulnerability. You should receive updates within a week.
|
||||||
|
|
||||||
|
|||||||
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/
|
||||||
413
android/README.md
Normal file
413
android/README.md
Normal file
@@ -0,0 +1,413 @@
|
|||||||
|
# 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 www.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.
|
||||||
|
|
||||||
|
---
|
||||||
|
## 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
|
||||||
|
|
||||||
|
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.
|
||||||
|
5. Submit for review.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Build web app for production and Sync assets to Android
|
||||||
|
yarn build-sync-android
|
||||||
|
|
||||||
|
# Build signed release APK in Android Studio
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Live Updates
|
||||||
|
|
||||||
|
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).
|
||||||
|
|
||||||
|
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 build-web-view
|
||||||
|
npx @capawesome/cli apps:bundles:create --path web/out
|
||||||
|
```
|
||||||
|
|
||||||
|
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.
|
||||||
|
Maybe we should add it to our CD. For example we set a file with `{liveUpdateVersion: 1}` and run the live update each time a push to main increments that counter.
|
||||||
|
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.
|
||||||
3
android/app/.gitignore
vendored
Normal file
3
android/app/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
/build/*
|
||||||
|
!/build/.npmkeep
|
||||||
|
/google-services.json
|
||||||
66
android/app/build.gradle
Normal file
66
android/app/build.gradle
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
apply plugin: 'com.android.application'
|
||||||
|
apply plugin: 'com.google.gms.google-services'
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace "com.compassconnections.app"
|
||||||
|
compileSdk 36
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "com.compassconnections.app"
|
||||||
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
|
versionCode 14
|
||||||
|
versionName "1.1.3"
|
||||||
|
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:!*~'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
minifyEnabled false
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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.4.0'
|
||||||
|
implementation 'com.google.firebase:firebase-auth:24.0.1'
|
||||||
|
}
|
||||||
|
|
||||||
|
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_21
|
||||||
|
targetCompatibility JavaVersion.VERSION_21
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
76
android/app/src/main/AndroidManifest.xml
Normal file
76
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<?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: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>-->
|
||||||
|
<!-- <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="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,173 @@
|
|||||||
|
package com.compassconnections.app;
|
||||||
|
|
||||||
|
import android.Manifest;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.content.pm.PackageManager;
|
||||||
|
import android.os.Build;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.webkit.JavascriptInterface;
|
||||||
|
import android.webkit.WebSettings;
|
||||||
|
import android.webkit.WebView;
|
||||||
|
|
||||||
|
import androidx.activity.result.ActivityResultLauncher;
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts;
|
||||||
|
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 org.json.JSONException;
|
||||||
|
import org.json.JSONObject;
|
||||||
|
|
||||||
|
import ee.forgr.capacitor.social.login.GoogleProvider;
|
||||||
|
import ee.forgr.capacitor.social.login.ModifiedMainActivityForSocialLoginPlugin;
|
||||||
|
import ee.forgr.capacitor.social.login.SocialLoginPlugin;
|
||||||
|
|
||||||
|
|
||||||
|
//import android.app.NotificationChannel;
|
||||||
|
//import android.app.NotificationManager;
|
||||||
|
//import android.os.Build;
|
||||||
|
//import com.google.firebase.messaging.RemoteMessage;
|
||||||
|
//import com.capacitorjs.plugins.pushnotifications.MessagingService;
|
||||||
|
|
||||||
|
//public class MyMessagingService extends MessagingService {
|
||||||
|
//
|
||||||
|
// @Override
|
||||||
|
// public void onMessageReceived(RemoteMessage remoteMessage) {
|
||||||
|
// // TODO(developer): Handle FCM messages here.
|
||||||
|
// // Not getting messages here? See why this may be: https://goo.gl/39bRNJ
|
||||||
|
// Log.d(TAG, "From: " + remoteMessage.getFrom());
|
||||||
|
//
|
||||||
|
// // Check if message contains a data payload.
|
||||||
|
// if (remoteMessage.getData().size() > 0) {
|
||||||
|
// Log.d(TAG, "Message data payload: " + remoteMessage.getData());
|
||||||
|
//
|
||||||
|
// if (/* Check if data needs to be processed by long running job */ true) {
|
||||||
|
// // For long-running tasks (10 seconds or more) use WorkManager.
|
||||||
|
// scheduleJob();
|
||||||
|
// } else {
|
||||||
|
// // Handle message within 10 seconds
|
||||||
|
// handleNow();
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Check if message contains a notification payload.
|
||||||
|
// if (remoteMessage.getNotification() != null) {
|
||||||
|
// Log.d(TAG, "Message Notification Body: " + remoteMessage.getNotification().getBody());
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// // Also if you intend on generating your own notifications as a result of a received FCM
|
||||||
|
// // message, here is where that should be initiated. See sendNotification method below.
|
||||||
|
// }
|
||||||
|
//}
|
||||||
|
|
||||||
|
|
||||||
|
public class MainActivity extends BridgeActivity implements ModifiedMainActivityForSocialLoginPlugin {
|
||||||
|
|
||||||
|
// 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 static class NativeBridge {
|
||||||
|
@JavascriptInterface
|
||||||
|
public boolean isNativeApp() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onNewIntent(Intent intent) {
|
||||||
|
super.onNewIntent(intent);
|
||||||
|
|
||||||
|
// String data = intent.getDataString();
|
||||||
|
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", "Payload: " + payload);
|
||||||
|
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript("bridgeRedirect(" + payload + ");", null));
|
||||||
|
} catch (JSONException e) {
|
||||||
|
Log.i("CompassApp", "Failed to encode JSON payload", e);
|
||||||
|
}
|
||||||
|
} 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));
|
||||||
|
|
||||||
|
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 NativeBridge(), "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();
|
||||||
|
}
|
||||||
|
|
||||||
|
@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() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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')
|
||||||
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'
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ module.exports = {
|
|||||||
parser: '@typescript-eslint/parser',
|
parser: '@typescript-eslint/parser',
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
tsconfigRootDir: __dirname,
|
tsconfigRootDir: __dirname,
|
||||||
project: ['./tsconfig.json'],
|
project: ['./tsconfig.json', './tsconfig.test.json'],
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
'@typescript-eslint/ban-types': [
|
'@typescript-eslint/ban-types': [
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ gcloud auth login
|
|||||||
gcloud config set project YOUR_PROJECT_ID
|
gcloud config set project YOUR_PROJECT_ID
|
||||||
```
|
```
|
||||||
|
|
||||||
|
You also need `opentofu` and `docker`. Try running this (from root) on Linux or macOS for a faster install:
|
||||||
|
```bash
|
||||||
|
./script/setup.sh
|
||||||
|
```
|
||||||
|
If it doesn't work, you can install them manually (google how to install `opentofu` and `docker` for your OS).
|
||||||
|
|
||||||
### Setup
|
### Setup
|
||||||
|
|
||||||
This section is only for the people who are creating a server from scratch, for instance for a forked project.
|
This section is only for the people who are creating a server from scratch, for instance for a forked project.
|
||||||
@@ -54,6 +60,9 @@ gcloud projects add-iam-policy-binding compass-130ba \
|
|||||||
--member="serviceAccount:253367029065-compute@developer.gserviceaccount.com" \
|
--member="serviceAccount:253367029065-compute@developer.gserviceaccount.com" \
|
||||||
--role="roles/secretmanager.secretAccessor"
|
--role="roles/secretmanager.secretAccessor"
|
||||||
gcloud run services list
|
gcloud run services list
|
||||||
|
gcloud compute backend-services update api-backend \
|
||||||
|
--global \
|
||||||
|
--timeout=600s
|
||||||
```
|
```
|
||||||
|
|
||||||
Set up the saved search notifications job:
|
Set up the saved search notifications job:
|
||||||
@@ -70,6 +79,30 @@ gcloud scheduler jobs create http daily-saved-search-notifications \
|
|||||||
|
|
||||||
View it [here](https://console.cloud.google.com/cloudscheduler).
|
View it [here](https://console.cloud.google.com/cloudscheduler).
|
||||||
|
|
||||||
|
##### API Deploy CD
|
||||||
|
|
||||||
|
```shell
|
||||||
|
gcloud iam service-accounts create ci-deployer \
|
||||||
|
--display-name="CI Deployer"
|
||||||
|
gcloud projects add-iam-policy-binding compass-130ba \
|
||||||
|
--member="serviceAccount:ci-deployer@compass-130ba.iam.gserviceaccount.com" \
|
||||||
|
--role="roles/artifactregistry.writer"
|
||||||
|
gcloud projects add-iam-policy-binding compass-130ba \
|
||||||
|
--member="serviceAccount:ci-deployer@compass-130ba.iam.gserviceaccount.com" \
|
||||||
|
--role="roles/storage.objectAdmin"
|
||||||
|
gcloud projects add-iam-policy-binding compass-130ba \
|
||||||
|
--member="serviceAccount:ci-deployer@compass-130ba.iam.gserviceaccount.com" \
|
||||||
|
--role="roles/storage.admin"
|
||||||
|
gcloud projects add-iam-policy-binding compass-130ba \
|
||||||
|
--member="serviceAccount:ci-deployer@compass-130ba.iam.gserviceaccount.com" \
|
||||||
|
--role="roles/compute.admin"
|
||||||
|
gcloud iam service-accounts add-iam-policy-binding \
|
||||||
|
253367029065-compute@developer.gserviceaccount.com \
|
||||||
|
--member="serviceAccount:ci-deployer@compass-130ba.iam.gserviceaccount.com" \
|
||||||
|
--role="roles/iam.serviceAccountUser"
|
||||||
|
gcloud iam service-accounts keys create keyfile.json --iam-account=ci-deployer@compass-130ba.iam.gserviceaccount.com
|
||||||
|
```
|
||||||
|
|
||||||
##### DNS
|
##### DNS
|
||||||
|
|
||||||
* After deployment, Terraform assigns a static external IP to this resource.
|
* After deployment, Terraform assigns a static external IP to this resource.
|
||||||
@@ -131,8 +164,9 @@ In root directory, run the local api with hot reload, along with all the other b
|
|||||||
|
|
||||||
### Deploy
|
### Deploy
|
||||||
|
|
||||||
Run in this directory to deploy your code to the server.
|
To deploy the backend code, simply increment the version number in [package.json](package.json) and push to the `main` branch.
|
||||||
|
|
||||||
|
Or if you have access to the project on google cloud, run in this directory:
|
||||||
```bash
|
```bash
|
||||||
./deploy-api.sh prod
|
./deploy-api.sh prod
|
||||||
```
|
```
|
||||||
@@ -158,5 +192,7 @@ docker rmi -f $(docker images -aq)
|
|||||||
|
|
||||||
### Documentation
|
### Documentation
|
||||||
|
|
||||||
The API docs are available at https://api.compassmeet.com. They are defined in [openapi.json](openapi.json).
|
The API doc is available at https://api.compassmeet.com. It's dynamically prepared in [app.ts](src/app.ts).
|
||||||
Just a few endpoints are mentioned in that JSON doc. Feel free to help by adding the remaining ones!
|
|
||||||
|
### Todo (Tests)
|
||||||
|
- [ ] Finish get-supabase-token unit test when endpoint is implemented
|
||||||
@@ -25,6 +25,19 @@ PROJECT="compass-130ba"
|
|||||||
SERVICE_NAME="api"
|
SERVICE_NAME="api"
|
||||||
|
|
||||||
GIT_REVISION=$(git rev-parse --short HEAD)
|
GIT_REVISION=$(git rev-parse --short HEAD)
|
||||||
|
GIT_COMMIT_DATE=$(git log -1 --format=%ci)
|
||||||
|
GIT_COMMIT_AUTHOR=$(git log -1 --format='%an')
|
||||||
|
|
||||||
|
cat > metadata.json << EOF
|
||||||
|
{
|
||||||
|
"git": {
|
||||||
|
"revision": "${GIT_REVISION}",
|
||||||
|
"commitDate": "${GIT_COMMIT_DATE}",
|
||||||
|
"author": "${GIT_COMMIT_AUTHOR}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
|
||||||
TIMESTAMP=$(date +"%s")
|
TIMESTAMP=$(date +"%s")
|
||||||
IMAGE_TAG="${TIMESTAMP}-${GIT_REVISION}"
|
IMAGE_TAG="${TIMESTAMP}-${GIT_REVISION}"
|
||||||
IMAGE_URL="${REGION}-docker.pkg.dev/${PROJECT}/builds/${SERVICE_NAME}:${IMAGE_TAG}"
|
IMAGE_URL="${REGION}-docker.pkg.dev/${PROJECT}/builds/${SERVICE_NAME}:${IMAGE_TAG}"
|
||||||
@@ -41,16 +54,16 @@ export TF_VAR_image_url=$IMAGE_URL
|
|||||||
export TF_VAR_env=$ENV
|
export TF_VAR_env=$ENV
|
||||||
tofu apply -auto-approve
|
tofu apply -auto-approve
|
||||||
|
|
||||||
INSTANCE_NAME=$(gcloud compute instances list \
|
#INSTANCE_NAME=$(gcloud compute instances list \
|
||||||
--filter="zone:(us-west1-c)" \
|
# --filter="zone:(us-west1-c)" \
|
||||||
--sort-by="~creationTimestamp" \
|
# --sort-by="~creationTimestamp" \
|
||||||
--format="value(name)" \
|
# --format="value(name)" \
|
||||||
--limit=1)
|
# --limit=1)
|
||||||
SERVICE_ACCOUNT_EMAIL=$(gcloud compute instances describe ${INSTANCE_NAME} \
|
#SERVICE_ACCOUNT_EMAIL=$(gcloud compute instances describe ${INSTANCE_NAME} \
|
||||||
--zone us-west1-c \
|
# --zone us-west1-c \
|
||||||
--format="value(serviceAccounts.email)")
|
# --format="value(serviceAccounts.email)")
|
||||||
gcloud projects add-iam-policy-binding ${PROJECT} \
|
#gcloud projects add-iam-policy-binding ${PROJECT} \
|
||||||
--member="serviceAccount:$SERVICE_ACCOUNT_EMAIL" \
|
# --member="serviceAccount:$SERVICE_ACCOUNT_EMAIL" \
|
||||||
--role="roles/artifactregistry.reader"
|
# --role="roles/artifactregistry.reader"
|
||||||
|
|
||||||
echo "✅ Deployment complete! Image: ${IMAGE_URL}"
|
echo "✅ Deployment complete! Image: ${IMAGE_URL}"
|
||||||
|
|||||||
31
backend/api/jest.config.js
Normal file
31
backend/api/jest.config.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
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,
|
||||||
|
|
||||||
|
globals: {
|
||||||
|
'ts-jest': {
|
||||||
|
tsconfig: "<rootDir>/tsconfig.test.json"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
collectCoverageFrom: [
|
||||||
|
"src/**/*.{ts,tsx}",
|
||||||
|
"!src/**/*.d.ts"
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -185,29 +185,29 @@ resource "google_compute_url_map" "api_url_map" {
|
|||||||
path_matcher {
|
path_matcher {
|
||||||
name = "allpaths"
|
name = "allpaths"
|
||||||
default_service = google_compute_backend_service.api_backend.self_link
|
default_service = google_compute_backend_service.api_backend.self_link
|
||||||
|
#
|
||||||
# Priority 0: passthrough /v0/* requests
|
# # Priority 0: passthrough /v0/* requests
|
||||||
route_rules {
|
# route_rules {
|
||||||
priority = 1
|
# priority = 1
|
||||||
match_rules {
|
# match_rules {
|
||||||
prefix_match = "/v0"
|
# prefix_match = "/v0"
|
||||||
}
|
# }
|
||||||
service = google_compute_backend_service.api_backend.self_link
|
# service = google_compute_backend_service.api_backend.self_link
|
||||||
}
|
# }
|
||||||
|
#
|
||||||
# Priority 1: rewrite everything else to /v0
|
# # Priority 1: rewrite everything else to /v0
|
||||||
route_rules {
|
# route_rules {
|
||||||
priority = 2
|
# priority = 2
|
||||||
match_rules {
|
# match_rules {
|
||||||
prefix_match = "/"
|
# prefix_match = "/"
|
||||||
}
|
# }
|
||||||
route_action {
|
# route_action {
|
||||||
url_rewrite {
|
# url_rewrite { # This may break websockets (the Upgrade and Connection headers must pass through untouched).
|
||||||
path_prefix_rewrite = "/v0/"
|
# path_prefix_rewrite = "/v0/"
|
||||||
}
|
# }
|
||||||
}
|
# }
|
||||||
service = google_compute_backend_service.api_backend.self_link
|
# service = google_compute_backend_service.api_backend.self_link
|
||||||
}
|
# }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
7
backend/api/metadata.json
Normal file
7
backend/api/metadata.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"git": {
|
||||||
|
"revision": "91f69ed",
|
||||||
|
"commitDate": "2025-12-04 20:51:09+0100",
|
||||||
|
"author": "MartinBraquet"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
{
|
|
||||||
"openapi": "3.0.0",
|
|
||||||
"info": {
|
|
||||||
"title": "Compass API",
|
|
||||||
"version": "1.0.0"
|
|
||||||
},
|
|
||||||
"paths": {
|
|
||||||
"/health": {
|
|
||||||
"get": {
|
|
||||||
"summary": "Health",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "OK"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"/get-profiles": {
|
|
||||||
"get": {
|
|
||||||
"summary": "List profiles",
|
|
||||||
"responses": {
|
|
||||||
"200": {
|
|
||||||
"description": "OK"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +1,28 @@
|
|||||||
{
|
{
|
||||||
"name": "@compass/api",
|
"name": "@compass/api",
|
||||||
"description": "Backend API endpoints",
|
"description": "Backend API endpoints",
|
||||||
"version": "1.0.0",
|
"version": "1.0.11",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"watch:serve": "tsx watch src/serve.ts",
|
||||||
"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: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": "yarn watch:serve",
|
||||||
"dev": "npx concurrently -n COMPILE,SERVER -c cyan,green \"yarn watch:compile\" \"yarn watch:serve\"",
|
"prod": "npx concurrently -n COMPILE,SERVER -c cyan,green \"yarn watch:compile\" \"yarn watch:serve\"",
|
||||||
"build": "yarn compile && yarn dist:clean && yarn dist:copy",
|
"build": "yarn compile && yarn dist:clean && yarn dist:copy",
|
||||||
"build:fast": "yarn compile && yarn dist:copy",
|
"build:fast": "yarn compile && yarn dist:copy",
|
||||||
"compile": "tsc -b && tsc-alias && (cd ../../common && tsc-alias) && (cd ../shared && tsc-alias) && (cd ../email && tsc-alias)",
|
"clean": "rm -rf lib && (cd ../../common && rm -rf lib) && (cd ../shared && rm -rf lib) && (cd ../email && rm -rf lib)",
|
||||||
|
"compile": "tsc -b && tsc-alias && (cd ../../common && tsc-alias) && (cd ../shared && tsc-alias) && (cd ../email && tsc-alias) && cp -r src/public/ lib/",
|
||||||
"debug": "nodemon -r tsconfig-paths/register --watch src -e ts --watch ../../common/src --watch ../shared/src --exec \"yarn build && node --inspect-brk src/serve.ts\"",
|
"debug": "nodemon -r tsconfig-paths/register --watch src -e ts --watch ../../common/src --watch ../shared/src --exec \"yarn build && node --inspect-brk src/serve.ts\"",
|
||||||
"dist": "yarn dist:clean && yarn dist:copy",
|
"dist": "yarn dist:clean && yarn dist:copy",
|
||||||
"dist:clean": "rm -rf dist && mkdir -p dist/common/lib dist/backend/shared/lib dist/backend/api/lib dist/backend/email/lib",
|
"dist:clean": "rm -rf dist && mkdir -p dist/common/lib dist/backend/shared/lib dist/backend/api/lib dist/backend/email/lib",
|
||||||
"dist:copy": "rsync -a --delete ../../common/lib/ dist/common/lib && rsync -a --delete ../shared/lib/ dist/backend/shared/lib && rsync -a --delete ../email/lib/ dist/backend/email/lib && rsync -a --delete ./lib/* dist/backend/api/lib && cp ../../yarn.lock dist && cp package.json dist && cp openapi.json dist",
|
"dist:copy": "rsync -a --delete ../../common/lib/ dist/common/lib && rsync -a --delete ../shared/lib/ dist/backend/shared/lib && rsync -a --delete ../email/lib/ dist/backend/email/lib && rsync -a --delete ./lib/* dist/backend/api/lib && cp ../../yarn.lock dist && cp package.json dist && cp package.json dist/backend/api && cp metadata.json dist && cp metadata.json dist/backend/api",
|
||||||
"watch": "tsc -w",
|
"watch": "tsc -w",
|
||||||
"verify": "yarn --cwd=../.. verify",
|
"verify": "yarn --cwd=../.. verify",
|
||||||
"verify:dir": "npx eslint . --max-warnings 0",
|
"verify:dir": "npx eslint . --max-warnings 0",
|
||||||
"regen-types": "cd ../supabase && make ENV=prod regen-types",
|
"regen-types": "cd ../supabase && make ENV=prod regen-types",
|
||||||
"regen-types-dev": "cd ../supabase && make ENV=dev regen-types"
|
"regen-types-dev": "cd ../supabase && make ENV=dev regen-types-dev",
|
||||||
|
"test": "jest --config jest.config.js",
|
||||||
|
"test:coverage": "jest --config jest.config.js --coverage"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
@@ -44,29 +48,32 @@
|
|||||||
"colors": "1.4.0",
|
"colors": "1.4.0",
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
"dayjs": "1.11.4",
|
"dayjs": "1.11.4",
|
||||||
"express": "4.18.1",
|
"express": "5.0.0",
|
||||||
"firebase-admin": "13.5.0",
|
"firebase-admin": "13.5.0",
|
||||||
"gcp-metadata": "6.1.0",
|
"gcp-metadata": "6.1.0",
|
||||||
"jsonwebtoken": "9.0.0",
|
"jsonwebtoken": "9.0.0",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
|
"openapi-types": "12.1.3",
|
||||||
"pg-promise": "11.4.1",
|
"pg-promise": "11.4.1",
|
||||||
"posthog-node": "4.11.0",
|
"posthog-node": "4.11.0",
|
||||||
|
"react": "18.2.0",
|
||||||
|
"react-dom": "18.2.0",
|
||||||
"resend": "4.1.2",
|
"resend": "4.1.2",
|
||||||
"string-similarity": "4.0.4",
|
"string-similarity": "4.0.4",
|
||||||
"swagger-jsdoc": "6.2.8",
|
"swagger-jsdoc": "6.2.8",
|
||||||
"swagger-ui-express": "5.0.1",
|
"swagger-ui-express": "5.0.1",
|
||||||
"tsconfig-paths": "4.2.0",
|
"tsconfig-paths": "4.2.0",
|
||||||
"twitter-api-v2": "1.15.0",
|
"twitter-api-v2": "1.15.0",
|
||||||
"ws": "8.17.0",
|
"web-push": "3.6.7",
|
||||||
"react": "18.2.0",
|
"ws": "8.17.1",
|
||||||
"react-dom": "18.2.0",
|
"zod": "3.22.3"
|
||||||
"zod": "3.21.4"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "2.8.17",
|
"@types/cors": "2.8.17",
|
||||||
"@types/react": "18.3.5",
|
"@types/react": "18.3.5",
|
||||||
"@types/react-dom": "18.3.0",
|
"@types/react-dom": "18.3.0",
|
||||||
"@types/swagger-ui-express": "4.1.8",
|
"@types/swagger-ui-express": "4.1.8",
|
||||||
|
"@types/web-push": "3.6.4",
|
||||||
"@types/ws": "8.5.10"
|
"@types/ws": "8.5.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {blockUser, unblockUser} from './block-user'
|
|||||||
import {getCompatibleProfilesHandler} from './compatible-profiles'
|
import {getCompatibleProfilesHandler} from './compatible-profiles'
|
||||||
import {createComment} from './create-comment'
|
import {createComment} from './create-comment'
|
||||||
import {createCompatibilityQuestion} from './create-compatibility-question'
|
import {createCompatibilityQuestion} from './create-compatibility-question'
|
||||||
|
import {setCompatibilityAnswer} from './set-compatibility-answer'
|
||||||
|
import {deleteCompatibilityAnswer} from './delete-compatibility-answer'
|
||||||
import {createProfile} from './create-profile'
|
import {createProfile} from './create-profile'
|
||||||
import {createUser} from './create-user'
|
import {createUser} from './create-user'
|
||||||
import {getCompatibilityQuestions} from './get-compatibililty-questions'
|
import {getCompatibilityQuestions} from './get-compatibililty-questions'
|
||||||
@@ -19,7 +21,6 @@ import {getLikesAndShips} from './get-likes-and-ships'
|
|||||||
import {getProfileAnswers} from './get-profile-answers'
|
import {getProfileAnswers} from './get-profile-answers'
|
||||||
import {getProfiles} from './get-profiles'
|
import {getProfiles} from './get-profiles'
|
||||||
import {getSupabaseToken} from './get-supabase-token'
|
import {getSupabaseToken} from './get-supabase-token'
|
||||||
import {getDisplayUser, getUser} from './get-user'
|
|
||||||
import {getMe} from './get-me'
|
import {getMe} from './get-me'
|
||||||
import {hasFreeLike} from './has-free-like'
|
import {hasFreeLike} from './has-free-like'
|
||||||
import {health} from './health'
|
import {health} from './health'
|
||||||
@@ -40,7 +41,7 @@ import {getCurrentPrivateUser} from './get-current-private-user'
|
|||||||
import {createPrivateUserMessage} from './create-private-user-message'
|
import {createPrivateUserMessage} from './create-private-user-message'
|
||||||
import {
|
import {
|
||||||
getChannelMemberships,
|
getChannelMemberships,
|
||||||
getChannelMessages,
|
getChannelMessagesEndpoint,
|
||||||
getLastSeenChannelTime,
|
getLastSeenChannelTime,
|
||||||
setChannelLastSeenTime,
|
setChannelLastSeenTime,
|
||||||
} from 'api/get-private-messages'
|
} from 'api/get-private-messages'
|
||||||
@@ -50,10 +51,38 @@ import {leavePrivateUserMessageChannel} from './leave-private-user-message-chann
|
|||||||
import {updatePrivateUserMessageChannel} from './update-private-user-message-channel'
|
import {updatePrivateUserMessageChannel} from './update-private-user-message-channel'
|
||||||
import {getNotifications} from './get-notifications'
|
import {getNotifications} from './get-notifications'
|
||||||
import {updateNotifSettings} from './update-notif-setting'
|
import {updateNotifSettings} from './update-notif-setting'
|
||||||
|
import {setLastOnlineTime} from './set-last-online-time'
|
||||||
import swaggerUi from "swagger-ui-express"
|
import swaggerUi from "swagger-ui-express"
|
||||||
import * as fs from "fs"
|
|
||||||
import {sendSearchNotifications} from "api/send-search-notifications";
|
import {sendSearchNotifications} from "api/send-search-notifications";
|
||||||
|
import {sendDiscordMessage} from "common/discord/core";
|
||||||
|
import {getMessagesCount} from "api/get-messages-count";
|
||||||
|
import {createVote} from "api/create-vote";
|
||||||
|
import {vote} from "api/vote";
|
||||||
|
import {contact} from "api/contact";
|
||||||
|
import {saveSubscription} from "api/save-subscription";
|
||||||
|
import {createBookmarkedSearch} from './create-bookmarked-search'
|
||||||
|
import {deleteBookmarkedSearch} from './delete-bookmarked-search'
|
||||||
|
import {OpenAPIV3} from 'openapi-types';
|
||||||
|
import {version as pkgVersion} from './../package.json'
|
||||||
|
import {git} from './../metadata.json'
|
||||||
|
import {z, ZodFirstPartyTypeKind, ZodTypeAny} from "zod";
|
||||||
|
import {getUser} from "api/get-user";
|
||||||
|
import {localSendTestEmail} from "api/test";
|
||||||
|
import path from "node:path";
|
||||||
|
import {saveSubscriptionMobile} from "api/save-subscription-mobile";
|
||||||
|
import {IS_LOCAL} from "common/hosting/constants";
|
||||||
|
import {editMessage} from "api/edit-message";
|
||||||
|
import {reactToMessage} from "api/react-to-message";
|
||||||
|
import {deleteMessage} from "api/delete-message";
|
||||||
|
import {updateOptions} from "api/update-options";
|
||||||
|
import {getOptions} from "api/get-options";
|
||||||
|
|
||||||
|
// const corsOptions: CorsOptions = {
|
||||||
|
// origin: ['*'], // Only allow requests from this domain
|
||||||
|
// methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
|
||||||
|
// allowedHeaders: ['Content-Type', 'Authorization'],
|
||||||
|
// credentials: true, // if you use cookies or auth headers
|
||||||
|
// };
|
||||||
const allowCorsUnrestricted: RequestHandler = cors({})
|
const allowCorsUnrestricted: RequestHandler = cors({})
|
||||||
|
|
||||||
function cacheController(policy?: string): RequestHandler {
|
function cacheController(policy?: string): RequestHandler {
|
||||||
@@ -101,33 +130,195 @@ const apiErrorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
|
|||||||
export const app = express()
|
export const app = express()
|
||||||
app.use(requestMonitoring)
|
app.use(requestMonitoring)
|
||||||
|
|
||||||
const swaggerDocument = JSON.parse(fs.readFileSync("./openapi.json", "utf-8"))
|
|
||||||
swaggerDocument.info = {
|
const schemaCache = new WeakMap<ZodTypeAny, any>();
|
||||||
...swaggerDocument.info,
|
|
||||||
description: "Compass is a free, open-source platform to help people form deep, meaningful, and lasting connections — whether platonic, romantic, or collaborative. It’s made possible by contributions from the community, including code, ideas, feedback, and donations. Unlike typical apps, Compass prioritizes values, interests, and personality over swipes and ads, giving you full control over who you discover and how you connect.",
|
export function zodToOpenApiSchema(zodObj: ZodTypeAny,): any {
|
||||||
version: "1.0.0",
|
if (schemaCache.has(zodObj)) {
|
||||||
contact: {
|
return schemaCache.get(zodObj);
|
||||||
name: "Compass",
|
|
||||||
email: "compass.meet.info@gmail.com",
|
|
||||||
url: "https://compassmeet.com"
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const rootPath = pathWithPrefix("/")
|
const def: any = (zodObj as any)._def;
|
||||||
app.get(rootPath, swaggerUi.setup(swaggerDocument))
|
const typeName = def.typeName as ZodFirstPartyTypeKind;
|
||||||
app.use(rootPath, swaggerUi.serve)
|
|
||||||
|
|
||||||
app.options('*', allowCorsUnrestricted)
|
// Placeholder so recursive references can point here
|
||||||
|
const placeholder: any = {};
|
||||||
|
schemaCache.set(zodObj, placeholder);
|
||||||
|
|
||||||
|
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 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;
|
||||||
|
|
||||||
|
// Include props in request body for POST/PUT
|
||||||
|
const operation: any = {
|
||||||
|
summary,
|
||||||
|
tags: [(config as any).tag ?? 'API'],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: 'OK',
|
||||||
|
content: {
|
||||||
|
'application/json': {
|
||||||
|
schema: {type: 'object'}, // could be improved by introspecting returns
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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',
|
||||||
|
};
|
||||||
|
const t = zodType as z.ZodTypeAny; // assert type to ZodTypeAny
|
||||||
|
return {
|
||||||
|
name: key,
|
||||||
|
in: 'query',
|
||||||
|
required: !(t.isOptional ?? false),
|
||||||
|
schema: {type: typeMap[t._def.typeName] ?? 'string'},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
paths[pathKey] = {
|
||||||
|
[method]: operation,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.authed) {
|
||||||
|
operation.security = [{BearerAuth: []}];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return paths;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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. It’s made possible by contributions from the community, including code, ideas, feedback, and donations. Unlike typical apps, Compass prioritizes values, interests, and personality over swipes and ads, giving you full control over who you discover and how you connect.\n Git: ${git.commitDate} (${git.revision}).`,
|
||||||
|
version: pkgVersion,
|
||||||
|
contact: {
|
||||||
|
name: "Compass",
|
||||||
|
email: "hello@compassmeet.com",
|
||||||
|
url: "https://compassmeet.com"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
paths: generateSwaggerPaths(API),
|
||||||
|
components: {
|
||||||
|
securitySchemes: {
|
||||||
|
BearerAuth: {
|
||||||
|
type: 'http',
|
||||||
|
scheme: 'bearer',
|
||||||
|
bearerFormat: 'JWT',
|
||||||
|
},
|
||||||
|
ApiKeyAuth: {
|
||||||
|
type: 'apiKey',
|
||||||
|
in: 'header',
|
||||||
|
name: 'x-api-key',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} 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> } = {
|
const handlers: { [k in APIPath]: APIHandler<k> } = {
|
||||||
health: health,
|
health: health,
|
||||||
'get-supabase-token': getSupabaseToken,
|
'get-supabase-token': getSupabaseToken,
|
||||||
'get-notifications': getNotifications,
|
'get-notifications': getNotifications,
|
||||||
'mark-all-notifs-read': markAllNotifsRead,
|
'mark-all-notifs-read': markAllNotifsRead,
|
||||||
'user/:username': getUser,
|
// 'user/:username': getUser,
|
||||||
'user/:username/lite': getDisplayUser,
|
// 'user/:username/lite': getDisplayUser,
|
||||||
'user/by-id/:id': getUser,
|
'user/by-id/:id': getUser,
|
||||||
'user/by-id/:id/lite': getDisplayUser,
|
// 'user/by-id/:id/lite': getDisplayUser,
|
||||||
'user/by-id/:id/block': blockUser,
|
'user/by-id/:id/block': blockUser,
|
||||||
'user/by-id/:id/unblock': unblockUser,
|
'user/by-id/:id/unblock': unblockUser,
|
||||||
'search-users': searchUsers,
|
'search-users': searchUsers,
|
||||||
@@ -153,6 +344,11 @@ const handlers: { [k in APIPath]: APIHandler<k> } = {
|
|||||||
'create-comment': createComment,
|
'create-comment': createComment,
|
||||||
'hide-comment': hideComment,
|
'hide-comment': hideComment,
|
||||||
'create-compatibility-question': createCompatibilityQuestion,
|
'create-compatibility-question': createCompatibilityQuestion,
|
||||||
|
'set-compatibility-answer': setCompatibilityAnswer,
|
||||||
|
'delete-compatibility-answer': deleteCompatibilityAnswer,
|
||||||
|
'create-vote': createVote,
|
||||||
|
'vote': vote,
|
||||||
|
'contact': contact,
|
||||||
'compatible-profiles': getCompatibleProfilesHandler,
|
'compatible-profiles': getCompatibleProfilesHandler,
|
||||||
'search-location': searchLocation,
|
'search-location': searchLocation,
|
||||||
'search-near-city': searchNearCity,
|
'search-near-city': searchNearCity,
|
||||||
@@ -161,9 +357,21 @@ const handlers: { [k in APIPath]: APIHandler<k> } = {
|
|||||||
'update-private-user-message-channel': updatePrivateUserMessageChannel,
|
'update-private-user-message-channel': updatePrivateUserMessageChannel,
|
||||||
'leave-private-user-message-channel': leavePrivateUserMessageChannel,
|
'leave-private-user-message-channel': leavePrivateUserMessageChannel,
|
||||||
'get-channel-memberships': getChannelMemberships,
|
'get-channel-memberships': getChannelMemberships,
|
||||||
'get-channel-messages': getChannelMessages,
|
'get-channel-messages': getChannelMessagesEndpoint,
|
||||||
'get-channel-seen-time': getLastSeenChannelTime,
|
'get-channel-seen-time': getLastSeenChannelTime,
|
||||||
'set-channel-seen-time': setChannelLastSeenTime,
|
'set-channel-seen-time': setChannelLastSeenTime,
|
||||||
|
'get-messages-count': getMessagesCount,
|
||||||
|
'set-last-online-time': setLastOnlineTime,
|
||||||
|
'save-subscription': saveSubscription,
|
||||||
|
'save-subscription-mobile': saveSubscriptionMobile,
|
||||||
|
'create-bookmarked-search': createBookmarkedSearch,
|
||||||
|
'delete-bookmarked-search': deleteBookmarkedSearch,
|
||||||
|
'delete-message': deleteMessage,
|
||||||
|
'edit-message': editMessage,
|
||||||
|
'react-to-message': reactToMessage,
|
||||||
|
'update-options': updateOptions,
|
||||||
|
'get-options': getOptions,
|
||||||
|
// 'auth-google': authGoogle,
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.entries(handlers).forEach(([path, handler]) => {
|
Object.entries(handlers).forEach(([path, handler]) => {
|
||||||
@@ -191,8 +399,6 @@ Object.entries(handlers).forEach(([path, handler]) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// console.log('COMPASS_API_KEY:', process.env.COMPASS_API_KEY)
|
|
||||||
|
|
||||||
// Internal Endpoints
|
// Internal Endpoints
|
||||||
app.post(pathWithPrefix("/internal/send-search-notifications"),
|
app.post(pathWithPrefix("/internal/send-search-notifications"),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
@@ -206,11 +412,117 @@ app.post(pathWithPrefix("/internal/send-search-notifications"),
|
|||||||
return res.status(200).json(result)
|
return res.status(200).json(result)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to send notifications:", 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"});
|
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',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
app.use(rootPath, swaggerUi.serve)
|
||||||
|
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
app.use(allowCorsUnrestricted, (req, res) => {
|
app.use(allowCorsUnrestricted, (req, res) => {
|
||||||
if (req.method === 'OPTIONS') {
|
if (req.method === 'OPTIONS') {
|
||||||
|
|||||||
37
backend/api/src/auth-google.ts
Normal file
37
backend/api/src/auth-google.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
// import {APIError, APIHandler} from './helpers/endpoint'
|
||||||
|
// import {GOOGLE_CLIENT_ID} from "common/constants";
|
||||||
|
// import {REDIRECT_URI} from "common/envs/constants";
|
||||||
|
//
|
||||||
|
// export const authGoogle: APIHandler<'auth-google'> = async (
|
||||||
|
// {code},
|
||||||
|
// _auth
|
||||||
|
// ) => {
|
||||||
|
// console.log('Google Auth Codes:', code)
|
||||||
|
// if (!code) return {success: false, result: {}}
|
||||||
|
//
|
||||||
|
// const body = {
|
||||||
|
// client_id: GOOGLE_CLIENT_ID,
|
||||||
|
// client_secret: process.env.GOOGLE_CLIENT_SECRET!,
|
||||||
|
// code: code as string,
|
||||||
|
// grant_type: 'authorization_code',
|
||||||
|
// redirect_uri: REDIRECT_URI,
|
||||||
|
// };
|
||||||
|
// console.log('Body:', body)
|
||||||
|
// const tokenRes = await fetch('https://oauth2.googleapis.com/token', {
|
||||||
|
// method: 'POST',
|
||||||
|
// headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||||
|
// body: new URLSearchParams(body),
|
||||||
|
// });
|
||||||
|
//
|
||||||
|
// const tokens = await tokenRes.json();
|
||||||
|
// if (tokens.error) {
|
||||||
|
// console.error('Google token error:', tokens);
|
||||||
|
// throw new APIError(400, 'Google token error: ' + JSON.stringify(tokens))
|
||||||
|
// }
|
||||||
|
// console.log('Google Tokens:', tokens);
|
||||||
|
//
|
||||||
|
// return {
|
||||||
|
// success: true,
|
||||||
|
// result: {tokens},
|
||||||
|
// }
|
||||||
|
// }
|
||||||
@@ -1,61 +1,29 @@
|
|||||||
import { groupBy, sortBy } from 'lodash'
|
import {type APIHandler} from 'api/helpers/endpoint'
|
||||||
import { APIError, type APIHandler } from 'api/helpers/endpoint'
|
import {createSupabaseDirectClient} from "shared/supabase/init";
|
||||||
import { getCompatibilityScore } from 'common/love/compatibility-score'
|
|
||||||
import {
|
|
||||||
getProfile,
|
|
||||||
getCompatibilityAnswers,
|
|
||||||
getGenderCompatibleProfiles,
|
|
||||||
} from 'shared/love/supabase'
|
|
||||||
import { log } from 'shared/utils'
|
|
||||||
|
|
||||||
export const getCompatibleProfilesHandler: APIHandler<
|
export const getCompatibleProfilesHandler: APIHandler<'compatible-profiles'> = async (props) => {
|
||||||
'compatible-profiles'
|
|
||||||
> = async (props) => {
|
|
||||||
return getCompatibleProfiles(props.userId)
|
return getCompatibleProfiles(props.userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getCompatibleProfiles = async (userId: string) => {
|
export const getCompatibleProfiles = async (
|
||||||
const profile = await getProfile(userId)
|
userId: string,
|
||||||
|
) => {
|
||||||
log('got profile', {
|
const pg = createSupabaseDirectClient()
|
||||||
id: profile?.id,
|
const scores = await pg.map(
|
||||||
userId: profile?.user_id,
|
`select *
|
||||||
username: profile?.user?.username,
|
from compatibility_scores
|
||||||
})
|
where score is not null
|
||||||
|
and (user_id_1 = $1 or user_id_2 = $1)`,
|
||||||
if (!profile) throw new APIError(404, 'Profile not found')
|
[userId],
|
||||||
|
(r) => [r.user_id_1 == userId ? r.user_id_2 : r.user_id_1, {score: r.score}] as const
|
||||||
const profiles = await getGenderCompatibleProfiles(profile)
|
|
||||||
|
|
||||||
const profileAnswers = await getCompatibilityAnswers([
|
|
||||||
userId,
|
|
||||||
...profiles.map((l) => l.user_id),
|
|
||||||
])
|
|
||||||
log('got profile answers ' + profileAnswers.length)
|
|
||||||
|
|
||||||
const answersByUserId = groupBy(profileAnswers, 'creator_id')
|
|
||||||
const profileCompatibilityScores = Object.fromEntries(
|
|
||||||
profiles.map(
|
|
||||||
(l) =>
|
|
||||||
[
|
|
||||||
l.user_id,
|
|
||||||
getCompatibilityScore(
|
|
||||||
answersByUserId[profile.user_id] ?? [],
|
|
||||||
answersByUserId[l.user_id] ?? []
|
|
||||||
),
|
|
||||||
] as const
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const sortedCompatibleProfiles = sortBy(
|
const profileCompatibilityScores = Object.fromEntries(scores)
|
||||||
profiles,
|
|
||||||
(l) => profileCompatibilityScores[l.user_id].score
|
// console.log('scores', profileCompatibilityScores)
|
||||||
).reverse()
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: 'success',
|
status: 'success',
|
||||||
profile,
|
|
||||||
compatibleProfiles: sortedCompatibleProfiles,
|
|
||||||
profileCompatibilityScores,
|
profileCompatibilityScores,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
46
backend/api/src/contact.ts
Normal file
46
backend/api/src/contact.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import {APIError, APIHandler} from './helpers/endpoint'
|
||||||
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
import {insert} from 'shared/supabase/utils'
|
||||||
|
import {tryCatch} from 'common/util/try-catch'
|
||||||
|
import {sendDiscordMessage} from "common/discord/core";
|
||||||
|
import {jsonToMarkdown} from "common/md";
|
||||||
|
|
||||||
|
// 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 new APIError(500, '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,
|
||||||
|
}
|
||||||
|
}
|
||||||
23
backend/api/src/create-bookmarked-search.ts
Normal file
23
backend/api/src/create-bookmarked-search.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import {APIHandler} from './helpers/endpoint'
|
||||||
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ export const createCompatibilityQuestion: APIHandler<
|
|||||||
const pg = createSupabaseDirectClient()
|
const pg = createSupabaseDirectClient()
|
||||||
|
|
||||||
const { data, error } = await tryCatch(
|
const { data, error } = await tryCatch(
|
||||||
insert(pg, 'love_questions', {
|
insert(pg, 'compatibility_prompts', {
|
||||||
creator_id: creator.id,
|
creator_id: creator.id,
|
||||||
question,
|
question,
|
||||||
answer_type: 'compatibility_multiple_choice',
|
answer_type: 'compatibility_multiple_choice',
|
||||||
|
|||||||
94
backend/api/src/create-notification.ts
Normal file
94
backend/api/src/create-notification.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import {createSupabaseDirectClient, SupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
import {Notification} from 'common/notifications'
|
||||||
|
import {insertNotificationToSupabase} from 'shared/supabase/notifications'
|
||||||
|
import {tryCatch} from "common/util/try-catch";
|
||||||
|
import {Row} from "common/supabase/utils";
|
||||||
|
|
||||||
|
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: 'https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/misc%2Fcompass-192.png?alt=media&token=9fd251c5-fc43-4375-b629-1a8f4bbe8185',
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { APIError, APIHandler } from 'api/helpers/endpoint'
|
|||||||
import { filterDefined } from 'common/util/array'
|
import { filterDefined } from 'common/util/array'
|
||||||
import { uniq } from 'lodash'
|
import { uniq } from 'lodash'
|
||||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||||
import { addUsersToPrivateMessageChannel } from 'api/junk-drawer/private-messages'
|
import { addUsersToPrivateMessageChannel } from 'api/helpers/private-messages'
|
||||||
import { getPrivateUser, getUser } from 'shared/utils'
|
import { getPrivateUser, getUser } from 'shared/utils'
|
||||||
|
|
||||||
export const createPrivateUserMessageChannel: APIHandler<
|
export const createPrivateUserMessageChannel: APIHandler<
|
||||||
|
|||||||
@@ -1,23 +1,25 @@
|
|||||||
import { APIError, APIHandler } from 'api/helpers/endpoint'
|
import {APIError, APIHandler} from 'api/helpers/endpoint'
|
||||||
import { getUser } from 'shared/utils'
|
import {getUser} from 'shared/utils'
|
||||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
import { MAX_COMMENT_JSON_LENGTH } from 'api/create-comment'
|
import {MAX_COMMENT_JSON_LENGTH} from 'api/create-comment'
|
||||||
import { createPrivateUserMessageMain } from 'api/junk-drawer/private-messages'
|
import {createPrivateUserMessageMain} from 'api/helpers/private-messages'
|
||||||
|
|
||||||
export const createPrivateUserMessage: APIHandler<
|
export const createPrivateUserMessage: APIHandler<
|
||||||
'create-private-user-message'
|
'create-private-user-message'
|
||||||
> = async (body, auth) => {
|
> = async (body, auth) => {
|
||||||
const { content, channelId } = body
|
const {content, channelId} = body
|
||||||
if (JSON.stringify(content).length > MAX_COMMENT_JSON_LENGTH) {
|
if (JSON.stringify(content).length > MAX_COMMENT_JSON_LENGTH) {
|
||||||
throw new APIError(
|
throw new APIError(
|
||||||
400,
|
400,
|
||||||
`Message JSON should be less than ${MAX_COMMENT_JSON_LENGTH}`
|
`Message JSON should be less than ${MAX_COMMENT_JSON_LENGTH}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const pg = createSupabaseDirectClient()
|
|
||||||
const creator = await getUser(auth.uid)
|
const creator = await getUser(auth.uid)
|
||||||
if (!creator) throw new APIError(401, 'Your account was not found')
|
if (!creator) throw new APIError(401, 'Your account was not found')
|
||||||
if (creator.isBannedFromPosting) throw new APIError(403, 'You are banned')
|
if (creator.isBannedFromPosting) throw new APIError(403, 'You are banned')
|
||||||
|
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
return await createPrivateUserMessageMain(
|
return await createPrivateUserMessageMain(
|
||||||
creator,
|
creator,
|
||||||
channelId,
|
channelId,
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { APIError, APIHandler } from 'api/helpers/endpoint'
|
import { APIError, APIHandler } from 'api/helpers/endpoint'
|
||||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||||
import { log, getUser } from 'shared/utils'
|
import { log, getUser } from 'shared/utils'
|
||||||
import { HOUR_MS } from 'common/util/time'
|
import {HOUR_MS, MINUTE_MS, sleep} from 'common/util/time'
|
||||||
import { removePinnedUrlFromPhotoUrls } from 'shared/love/parse-photos'
|
import { removePinnedUrlFromPhotoUrls } from 'shared/profiles/parse-photos'
|
||||||
import { track } from 'shared/analytics'
|
import { track } from 'shared/analytics'
|
||||||
import { updateUser } from 'shared/supabase/users'
|
import { updateUser } from 'shared/supabase/users'
|
||||||
import { tryCatch } from 'common/util/try-catch'
|
import { tryCatch } from 'common/util/try-catch'
|
||||||
import { insert } from 'shared/supabase/utils'
|
import { insert } from 'shared/supabase/utils'
|
||||||
|
import {sendDiscordMessage} from "common/discord/core";
|
||||||
|
import {jsonToMarkdown} from "common/md";
|
||||||
|
|
||||||
export const createProfile: APIHandler<'create-profile'> = async (body, auth) => {
|
export const createProfile: APIHandler<'create-profile'> = async (body, auth) => {
|
||||||
const pg = createSupabaseDirectClient()
|
const pg = createSupabaseDirectClient()
|
||||||
@@ -28,7 +30,7 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
|
|||||||
updateUser(pg, auth.uid, { avatarUrl: body.pinned_url })
|
updateUser(pg, auth.uid, { avatarUrl: body.pinned_url })
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('body', body)
|
console.debug('body', body)
|
||||||
|
|
||||||
const { data, error } = await tryCatch(
|
const { data, error } = await tryCatch(
|
||||||
insert(pg, 'profiles', { user_id: auth.uid, ...body })
|
insert(pg, 'profiles', { user_id: auth.uid, ...body })
|
||||||
@@ -39,8 +41,56 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
|
|||||||
throw new APIError(500, 'Error creating user')
|
throw new APIError(500, 'Error creating user')
|
||||||
}
|
}
|
||||||
|
|
||||||
log('Created user', data)
|
log('Created profile', data)
|
||||||
await track(user.id, 'create profile', { username: user.username })
|
|
||||||
|
|
||||||
return data
|
const continuation = async () => {
|
||||||
|
try {
|
||||||
|
await track(auth.uid, 'create profile', {username: user.username})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to track create profile', e)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
// Let the user fill in the optional form with all their info and pictures before notifying discord of their arrival.
|
||||||
|
// So we can sse their full profile as soon as we get the notif on discord. And that allows OG to pull their pic for the link preview.
|
||||||
|
// Regardless, you need to wait for at least 5 seconds that the profile is fully in the db—otherwise ISR may cache "profile not created yet"
|
||||||
|
await sleep(10 * MINUTE_MS)
|
||||||
|
let message: string = `[**${user.name}**](https://www.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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
console.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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,28 @@
|
|||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { PrivateUser } from 'common/user'
|
import {PrivateUser} from 'common/user'
|
||||||
import { randomString } from 'common/util/random'
|
import {randomString} from 'common/util/random'
|
||||||
import { cleanDisplayName, cleanUsername } from 'common/util/clean-username'
|
import {cleanDisplayName, cleanUsername} from 'common/util/clean-username'
|
||||||
import { getIp, track } from 'shared/analytics'
|
import {getIp, track} from 'shared/analytics'
|
||||||
import { APIError, APIHandler } from './helpers/endpoint'
|
import {APIError, APIHandler} from './helpers/endpoint'
|
||||||
import { getDefaultNotificationPreferences } from 'common/user-notification-preferences'
|
import {getDefaultNotificationPreferences} from 'common/user-notification-preferences'
|
||||||
import { removeUndefinedProps } from 'common/util/object'
|
import {removeUndefinedProps} from 'common/util/object'
|
||||||
import { generateAvatarUrl } from 'shared/helpers/generate-and-update-avatar-urls'
|
import {generateAvatarUrl} from 'shared/helpers/generate-and-update-avatar-urls'
|
||||||
import { getStorage } from 'firebase-admin/storage'
|
import {RESERVED_PATHS} from 'common/envs/constants'
|
||||||
import { DEV_CONFIG } from 'common/envs/dev'
|
import {getUser, getUserByUsername, log} from 'shared/utils'
|
||||||
import { PROD_CONFIG } from 'common/envs/prod'
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
import { RESERVED_PATHS } from 'common/envs/constants'
|
import {insert} from 'shared/supabase/utils'
|
||||||
import { log, isProd, getUser, getUserByUsername } from 'shared/utils'
|
import {convertPrivateUser, convertUser} from 'common/supabase/users'
|
||||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
import {getBucket} from "shared/firebase-utils";
|
||||||
import { insert } from 'shared/supabase/utils'
|
import {sendWelcomeEmail} from "email/functions/helpers";
|
||||||
import { convertPrivateUser, convertUser } from 'common/supabase/users'
|
import {setLastOnlineTimeUser} from "api/set-last-online-time";
|
||||||
|
import {IS_LOCAL} from "common/hosting/constants";
|
||||||
|
|
||||||
export const createUser: APIHandler<'create-user'> = async (
|
export const createUser: APIHandler<'create-user'> = async (
|
||||||
props,
|
props,
|
||||||
auth,
|
auth,
|
||||||
req
|
req
|
||||||
) => {
|
) => {
|
||||||
const { deviceToken: preDeviceToken } = props
|
const {deviceToken: preDeviceToken} = props
|
||||||
const firebaseUser = await admin.auth().getUser(auth.uid)
|
const firebaseUser = await admin.auth().getUser(auth.uid)
|
||||||
|
|
||||||
const testUserAKAEmailPasswordUser =
|
const testUserAKAEmailPasswordUser =
|
||||||
@@ -52,7 +53,7 @@ export const createUser: APIHandler<'create-user'> = async (
|
|||||||
const rawName = fbUser.displayName || emailName || 'User' + randomString(4)
|
const rawName = fbUser.displayName || emailName || 'User' + randomString(4)
|
||||||
const name = cleanDisplayName(rawName)
|
const name = cleanDisplayName(rawName)
|
||||||
|
|
||||||
const bucket = getStorage().bucket(getStorageBucketId())
|
const bucket = getBucket()
|
||||||
const avatarUrl = fbUser.photoURL
|
const avatarUrl = fbUser.photoURL
|
||||||
? fbUser.photoURL
|
? fbUser.photoURL
|
||||||
: await generateAvatarUrl(auth.uid, name, bucket)
|
: await generateAvatarUrl(auth.uid, name, bucket)
|
||||||
@@ -63,7 +64,9 @@ export const createUser: APIHandler<'create-user'> = async (
|
|||||||
|
|
||||||
// Check username case-insensitive
|
// Check username case-insensitive
|
||||||
const dupes = await pg.one<number>(
|
const dupes = await pg.one<number>(
|
||||||
`select count(*) from users where username ilike $1`,
|
`select count(*)
|
||||||
|
from users
|
||||||
|
where username ilike $1`,
|
||||||
[username],
|
[username],
|
||||||
(r) => r.count
|
(r) => r.count
|
||||||
)
|
)
|
||||||
@@ -71,7 +74,7 @@ export const createUser: APIHandler<'create-user'> = async (
|
|||||||
const isReservedName = RESERVED_PATHS.includes(username)
|
const isReservedName = RESERVED_PATHS.includes(username)
|
||||||
if (usernameExists || isReservedName) username += randomString(4)
|
if (usernameExists || isReservedName) username += randomString(4)
|
||||||
|
|
||||||
const { user, privateUser } = await pg.tx(async (tx) => {
|
const {user, privateUser} = await pg.tx(async (tx) => {
|
||||||
const preexistingUser = await getUser(auth.uid, tx)
|
const preexistingUser = await getUser(auth.uid, tx)
|
||||||
if (preexistingUser)
|
if (preexistingUser)
|
||||||
throw new APIError(403, 'User already exists', {
|
throw new APIError(403, 'User already exists', {
|
||||||
@@ -81,13 +84,13 @@ export const createUser: APIHandler<'create-user'> = async (
|
|||||||
// Check exact username to avoid problems with duplicate requests
|
// Check exact username to avoid problems with duplicate requests
|
||||||
const sameNameUser = await getUserByUsername(username, tx)
|
const sameNameUser = await getUserByUsername(username, tx)
|
||||||
if (sameNameUser)
|
if (sameNameUser)
|
||||||
throw new APIError(403, 'Username already taken', { username })
|
throw new APIError(403, 'Username already taken', {username})
|
||||||
|
|
||||||
const user = removeUndefinedProps({
|
const user = removeUndefinedProps({
|
||||||
avatarUrl,
|
avatarUrl,
|
||||||
isBannedFromPosting: Boolean(
|
isBannedFromPosting: Boolean(
|
||||||
(deviceToken && bannedDeviceTokens.includes(deviceToken)) ||
|
(deviceToken && bannedDeviceTokens.includes(deviceToken)) ||
|
||||||
(ip && bannedIpAddresses.includes(ip))
|
(ip && bannedIpAddresses.includes(ip))
|
||||||
),
|
),
|
||||||
link: {},
|
link: {},
|
||||||
})
|
})
|
||||||
@@ -120,10 +123,24 @@ export const createUser: APIHandler<'create-user'> = async (
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
log('created user ', { username: user.username, firebaseId: auth.uid })
|
log('created user ', {username: user.username, firebaseId: auth.uid})
|
||||||
|
|
||||||
const continuation = async () => {
|
const continuation = async () => {
|
||||||
await track(auth.uid, 'create profile', { username: user.username })
|
try {
|
||||||
|
await track(auth.uid, 'create profile', {username: user.username})
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to track create profile', e)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!IS_LOCAL) await sendWelcomeEmail(user, privateUser)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to sendWelcomeEmail', e)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await setLastOnlineTimeUser(auth.uid)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to set last online time', e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -135,12 +152,6 @@ export const createUser: APIHandler<'create-user'> = async (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getStorageBucketId() {
|
|
||||||
return isProd()
|
|
||||||
? PROD_CONFIG.firebaseConfig.storageBucket
|
|
||||||
: DEV_CONFIG.firebaseConfig.storageBucket
|
|
||||||
}
|
|
||||||
|
|
||||||
// Automatically ban users with these device tokens or ip addresses.
|
// Automatically ban users with these device tokens or ip addresses.
|
||||||
const bannedDeviceTokens = [
|
const bannedDeviceTokens = [
|
||||||
'fa807d664415',
|
'fa807d664415',
|
||||||
|
|||||||
28
backend/api/src/create-vote.ts
Normal file
28
backend/api/src/create-vote.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
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'
|
||||||
|
|
||||||
|
export const createVote: APIHandler<
|
||||||
|
'create-vote'
|
||||||
|
> = async ({ title, description, isAnonymous }, auth) => {
|
||||||
|
const creator = await getUser(auth.uid)
|
||||||
|
if (!creator) throw new APIError(401, 'Your account was not found')
|
||||||
|
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
|
|
||||||
|
const { data, error } = await tryCatch(
|
||||||
|
insert(pg, 'votes', {
|
||||||
|
creator_id: creator.id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
is_anonymous: isAnonymous,
|
||||||
|
status: 'voting_open',
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error) throw new APIError(401, 'Error creating question')
|
||||||
|
|
||||||
|
return { data }
|
||||||
|
}
|
||||||
23
backend/api/src/delete-bookmarked-search.ts
Normal file
23
backend/api/src/delete-bookmarked-search.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import {APIHandler} from './helpers/endpoint'
|
||||||
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
|
||||||
|
export const deleteBookmarkedSearch: APIHandler<'delete-bookmarked-search'> = async (
|
||||||
|
props,
|
||||||
|
auth
|
||||||
|
) => {
|
||||||
|
const creator_id = auth.uid
|
||||||
|
const {id} = props
|
||||||
|
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
|
|
||||||
|
// Only allow deleting your own bookmarked searches
|
||||||
|
await pg.none(
|
||||||
|
`
|
||||||
|
DELETE FROM bookmarked_searches
|
||||||
|
WHERE id = $1 AND creator_id = $2
|
||||||
|
`,
|
||||||
|
[id, creator_id]
|
||||||
|
)
|
||||||
|
|
||||||
|
return {}
|
||||||
|
}
|
||||||
41
backend/api/src/delete-compatibility-answer.ts
Normal file
41
backend/api/src/delete-compatibility-answer.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import {APIHandler} from 'api/helpers/endpoint'
|
||||||
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
import {APIError} from 'common/api/utils'
|
||||||
|
import {recomputeCompatibilityScoresForUser} from 'shared/compatibility/compute-scores'
|
||||||
|
|
||||||
|
export const deleteCompatibilityAnswer: APIHandler<'delete-compatibility-answer'> = async (
|
||||||
|
{id}, auth) => {
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
|
|
||||||
|
// Verify user is the answer author
|
||||||
|
const item = await pg.oneOrNone(
|
||||||
|
`SELECT *
|
||||||
|
FROM compatibility_answers
|
||||||
|
WHERE id = $1
|
||||||
|
AND creator_id = $2`,
|
||||||
|
[id, auth.uid]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!item) {
|
||||||
|
throw new APIError(404, 'Item not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the answer
|
||||||
|
await pg.none(
|
||||||
|
`DELETE
|
||||||
|
FROM compatibility_answers
|
||||||
|
WHERE id = $1
|
||||||
|
AND creator_id = $2`,
|
||||||
|
[id, auth.uid]
|
||||||
|
)
|
||||||
|
|
||||||
|
const continuation = async () => {
|
||||||
|
// Recompute precomputed compatibility scores for this user
|
||||||
|
await recomputeCompatibilityScoresForUser(auth.uid, pg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'success',
|
||||||
|
continue: continuation,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,28 +1,33 @@
|
|||||||
import { getUser } from 'shared/utils'
|
import {getUser} from 'shared/utils'
|
||||||
import { APIError, APIHandler } from './helpers/endpoint'
|
import {APIError, APIHandler} from './helpers/endpoint'
|
||||||
import { updatePrivateUser, updateUser } from 'shared/supabase/users'
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
import * as admin from "firebase-admin";
|
||||||
import { FieldVal } from 'shared/supabase/utils'
|
import {deleteUserFiles} from "shared/firebase-utils";
|
||||||
|
|
||||||
export const deleteMe: APIHandler<'me/delete'> = async (body, auth) => {
|
export const deleteMe: APIHandler<'me/delete'> = async (_, auth) => {
|
||||||
const { username } = body
|
|
||||||
const user = await getUser(auth.uid)
|
const user = await getUser(auth.uid)
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new APIError(401, 'Your account was not found')
|
throw new APIError(401, 'Your account was not found')
|
||||||
}
|
}
|
||||||
if (user.username != username) {
|
const userId = user.id
|
||||||
throw new APIError(
|
if (!userId) {
|
||||||
400,
|
throw new APIError(400, 'Invalid user ID')
|
||||||
`Incorrect username. You are logged in as ${user.username}. Are you sure you want to delete this account?`
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove user data from Supabase
|
||||||
const pg = createSupabaseDirectClient()
|
const pg = createSupabaseDirectClient()
|
||||||
await updateUser(pg, auth.uid, {
|
await pg.none('DELETE FROM users WHERE id = $1', [userId])
|
||||||
userDeleted: true,
|
// Should cascade delete in other tables
|
||||||
isBannedFromPosting: true,
|
|
||||||
})
|
// Delete user files from Firebase Storage
|
||||||
await updatePrivateUser(pg, auth.uid, {
|
await deleteUserFiles(user.username)
|
||||||
email: FieldVal.delete(),
|
|
||||||
})
|
// Remove user from Firebase Auth
|
||||||
|
try {
|
||||||
|
const auth = admin.auth()
|
||||||
|
await auth.deleteUser(userId)
|
||||||
|
console.debug(`Deleted user ${userId} from Firebase Auth and Supabase`)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error deleting user from Firebase Auth:', e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
64
backend/api/src/delete-message.ts
Normal file
64
backend/api/src/delete-message.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import {APIError, APIHandler} from './helpers/endpoint'
|
||||||
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
import {broadcastPrivateMessages} from "api/helpers/private-messages";
|
||||||
|
|
||||||
|
// const DELETED_MESSAGE_CONTENT: JSONContent = {
|
||||||
|
// type: 'doc',
|
||||||
|
// content: [
|
||||||
|
// {
|
||||||
|
// type: 'paragraph',
|
||||||
|
// content: [
|
||||||
|
// {
|
||||||
|
// type: 'text',
|
||||||
|
// text: '[deleted]',
|
||||||
|
// },
|
||||||
|
// ],
|
||||||
|
// },
|
||||||
|
// ],
|
||||||
|
// }
|
||||||
|
|
||||||
|
export const deleteMessage: APIHandler<'delete-message'> = async ({messageId}, auth) => {
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
|
|
||||||
|
// Verify user is the message author and message is not too old
|
||||||
|
const message = await pg.oneOrNone(
|
||||||
|
`SELECT *
|
||||||
|
FROM private_user_messages
|
||||||
|
WHERE id = $1
|
||||||
|
AND user_id = $2`,
|
||||||
|
[messageId, auth.uid]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
throw new APIError(404, 'Message not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Soft delete the message
|
||||||
|
// await pg.none(
|
||||||
|
// `UPDATE private_user_messages
|
||||||
|
// SET deleted = TRUE,
|
||||||
|
// content = $2::jsonb,
|
||||||
|
// ciphertext = NULL,
|
||||||
|
// iv = NULL,
|
||||||
|
// tag = NULL
|
||||||
|
// WHERE id = $1`,
|
||||||
|
// [messageId, DELETED_MESSAGE_CONTENT]
|
||||||
|
// )
|
||||||
|
|
||||||
|
// Hard delete the message
|
||||||
|
await pg.none(
|
||||||
|
`DELETE
|
||||||
|
FROM private_user_messages
|
||||||
|
WHERE id = $1
|
||||||
|
AND user_id = $2`,
|
||||||
|
[messageId, auth.uid]
|
||||||
|
)
|
||||||
|
|
||||||
|
void broadcastPrivateMessages(pg, message.channel_id, auth.uid)
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('broadcastPrivateMessages failed', err)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {success: true}
|
||||||
|
}
|
||||||
|
|
||||||
44
backend/api/src/edit-message.ts
Normal file
44
backend/api/src/edit-message.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import {APIError, APIHandler} from './helpers/endpoint'
|
||||||
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
import {encryptMessage} from "shared/encryption";
|
||||||
|
import {broadcastPrivateMessages} from "api/helpers/private-messages";
|
||||||
|
|
||||||
|
|
||||||
|
export const editMessage: APIHandler<'edit-message'> = async ({messageId, content}, auth) => {
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
|
|
||||||
|
// Verify user is the message author and message is not too old
|
||||||
|
const message = await pg.oneOrNone(
|
||||||
|
`SELECT *
|
||||||
|
FROM private_user_messages
|
||||||
|
WHERE id = $1
|
||||||
|
AND user_id = $2
|
||||||
|
-- AND created_time > NOW() - INTERVAL '1 day'
|
||||||
|
AND deleted = FALSE`,
|
||||||
|
[messageId, auth.uid]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
throw new APIError(404, 'Message not found or cannot be edited')
|
||||||
|
}
|
||||||
|
|
||||||
|
const plaintext = JSON.stringify(content)
|
||||||
|
const {ciphertext, iv, tag} = encryptMessage(plaintext)
|
||||||
|
await pg.none(
|
||||||
|
`UPDATE private_user_messages
|
||||||
|
SET ciphertext = $1,
|
||||||
|
iv = $2,
|
||||||
|
tag = $3,
|
||||||
|
is_edited = TRUE,
|
||||||
|
edited_at = NOW()
|
||||||
|
WHERE id = $4`,
|
||||||
|
[ciphertext, iv, tag, messageId]
|
||||||
|
)
|
||||||
|
|
||||||
|
void broadcastPrivateMessages(pg, message.channel_id, auth.uid)
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('broadcastPrivateMessages failed', err)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {success: true}
|
||||||
|
}
|
||||||
@@ -2,37 +2,47 @@ import { type APIHandler } from 'api/helpers/endpoint'
|
|||||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||||
import { Row } from 'common/supabase/utils'
|
import { Row } from 'common/supabase/utils'
|
||||||
|
|
||||||
|
export function shuffle<T>(array: T[]): T[] {
|
||||||
|
const arr = [...array]; // copy to avoid mutating the original
|
||||||
|
for (let i = arr.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||||
|
}
|
||||||
|
return arr;
|
||||||
|
}
|
||||||
|
|
||||||
export const getCompatibilityQuestions: APIHandler<
|
export const getCompatibilityQuestions: APIHandler<
|
||||||
'get-compatibility-questions'
|
'get-compatibility-questions'
|
||||||
> = async (_props, _auth) => {
|
> = async (_props, _auth) => {
|
||||||
const pg = createSupabaseDirectClient()
|
const pg = createSupabaseDirectClient()
|
||||||
|
|
||||||
const questions = await pg.manyOrNone<
|
const questions = await pg.manyOrNone<
|
||||||
Row<'love_questions'> & { answer_count: number; score: number }
|
Row<'compatibility_prompts'> & { answer_count: number; score: number }
|
||||||
>(
|
>(
|
||||||
`SELECT
|
`SELECT
|
||||||
love_questions.*,
|
compatibility_prompts.*,
|
||||||
COUNT(love_compatibility_answers.question_id) as answer_count,
|
COUNT(compatibility_answers.question_id) as answer_count,
|
||||||
AVG(POWER(love_compatibility_answers.importance + 1 + CASE WHEN love_compatibility_answers.explanation IS NULL THEN 1 ELSE 0 END, 2)) as score
|
AVG(POWER(compatibility_answers.importance + 1 + CASE WHEN compatibility_answers.explanation IS NULL THEN 1 ELSE 0 END, 2)) as score
|
||||||
FROM
|
FROM
|
||||||
love_questions
|
compatibility_prompts
|
||||||
LEFT JOIN
|
LEFT JOIN
|
||||||
love_compatibility_answers ON love_questions.id = love_compatibility_answers.question_id
|
compatibility_answers ON compatibility_prompts.id = compatibility_answers.question_id
|
||||||
WHERE
|
WHERE
|
||||||
love_questions.answer_type = 'compatibility_multiple_choice'
|
compatibility_prompts.answer_type = 'compatibility_multiple_choice'
|
||||||
GROUP BY
|
GROUP BY
|
||||||
love_questions.id
|
compatibility_prompts.id
|
||||||
ORDER BY
|
ORDER BY
|
||||||
score DESC
|
compatibility_prompts.importance_score
|
||||||
`,
|
`,
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
if (false)
|
// const questions = shuffle(dbQuestions)
|
||||||
console.log(
|
|
||||||
'got questions',
|
// console.debug(
|
||||||
questions.map((q) => q.question + ' ' + q.score)
|
// 'got questions',
|
||||||
)
|
// questions.map((q) => q.question + ' ' + q.score)
|
||||||
|
// )
|
||||||
|
|
||||||
return {
|
return {
|
||||||
status: 'success',
|
status: 'success',
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ export const getLikesAndShipsMain = async (userId: string) => {
|
|||||||
created_time: number
|
created_time: number
|
||||||
}>(
|
}>(
|
||||||
`
|
`
|
||||||
select target_id, love_likes.created_time
|
select target_id, profile_likes.created_time
|
||||||
from love_likes
|
from profile_likes
|
||||||
join profiles on profiles.user_id = love_likes.target_id
|
join profiles on profiles.user_id = profile_likes.target_id
|
||||||
join users on users.id = love_likes.target_id
|
join users on users.id = profile_likes.target_id
|
||||||
where creator_id = $1
|
where creator_id = $1
|
||||||
and looking_for_matches
|
and looking_for_matches
|
||||||
and profiles.pinned_url is not null
|
and profiles.pinned_url is not null
|
||||||
@@ -42,10 +42,10 @@ export const getLikesAndShipsMain = async (userId: string) => {
|
|||||||
created_time: number
|
created_time: number
|
||||||
}>(
|
}>(
|
||||||
`
|
`
|
||||||
select creator_id, love_likes.created_time
|
select creator_id, profile_likes.created_time
|
||||||
from love_likes
|
from profile_likes
|
||||||
join profiles on profiles.user_id = love_likes.creator_id
|
join profiles on profiles.user_id = profile_likes.creator_id
|
||||||
join users on users.id = love_likes.creator_id
|
join users on users.id = profile_likes.creator_id
|
||||||
where target_id = $1
|
where target_id = $1
|
||||||
and looking_for_matches
|
and looking_for_matches
|
||||||
and profiles.pinned_url is not null
|
and profiles.pinned_url is not null
|
||||||
@@ -68,11 +68,11 @@ export const getLikesAndShipsMain = async (userId: string) => {
|
|||||||
}>(
|
}>(
|
||||||
`
|
`
|
||||||
select
|
select
|
||||||
target1_id, target2_id, creator_id, love_ships.created_time,
|
target1_id, target2_id, creator_id, profile_ships.created_time,
|
||||||
target1_id as target_id
|
target1_id as target_id
|
||||||
from love_ships
|
from profile_ships
|
||||||
join profiles on profiles.user_id = love_ships.target1_id
|
join profiles on profiles.user_id = profile_ships.target1_id
|
||||||
join users on users.id = love_ships.target1_id
|
join users on users.id = profile_ships.target1_id
|
||||||
where target2_id = $1
|
where target2_id = $1
|
||||||
and profiles.looking_for_matches
|
and profiles.looking_for_matches
|
||||||
and profiles.pinned_url is not null
|
and profiles.pinned_url is not null
|
||||||
@@ -81,11 +81,11 @@ export const getLikesAndShipsMain = async (userId: string) => {
|
|||||||
union all
|
union all
|
||||||
|
|
||||||
select
|
select
|
||||||
target1_id, target2_id, creator_id, love_ships.created_time,
|
target1_id, target2_id, creator_id, profile_ships.created_time,
|
||||||
target2_id as target_id
|
target2_id as target_id
|
||||||
from love_ships
|
from profile_ships
|
||||||
join profiles on profiles.user_id = love_ships.target2_id
|
join profiles on profiles.user_id = profile_ships.target2_id
|
||||||
join users on users.id = love_ships.target2_id
|
join users on users.id = profile_ships.target2_id
|
||||||
where target1_id = $1
|
where target1_id = $1
|
||||||
and profiles.looking_for_matches
|
and profiles.looking_for_matches
|
||||||
and profiles.pinned_url is not null
|
and profiles.pinned_url is not null
|
||||||
|
|||||||
18
backend/api/src/get-messages-count.ts
Normal file
18
backend/api/src/get-messages-count.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import {APIHandler} from './helpers/endpoint'
|
||||||
|
import {createSupabaseDirectClient} from "shared/supabase/init";
|
||||||
|
|
||||||
|
export const getMessagesCount: APIHandler<'get-messages-count'> = async (_, _auth) => {
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
|
const result = await pg.one(
|
||||||
|
`
|
||||||
|
SELECT COUNT(*) AS count
|
||||||
|
FROM private_user_messages;
|
||||||
|
`,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const count = Number(result.count);
|
||||||
|
console.debug('private_user_messages count:', count);
|
||||||
|
return {
|
||||||
|
count: count,
|
||||||
|
}
|
||||||
|
}
|
||||||
28
backend/api/src/get-options.ts
Normal file
28
backend/api/src/get-options.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import {APIError, APIHandler} from 'api/helpers/endpoint'
|
||||||
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
import {log} from 'shared/utils'
|
||||||
|
import {tryCatch} from 'common/util/try-catch'
|
||||||
|
import {OPTION_TABLES} from "common/profiles/constants";
|
||||||
|
|
||||||
|
export const getOptions: APIHandler<'get-options'> = async (
|
||||||
|
{table},
|
||||||
|
_auth
|
||||||
|
) => {
|
||||||
|
if (!OPTION_TABLES.includes(table)) throw new APIError(400, 'Invalid table')
|
||||||
|
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
|
|
||||||
|
const result = await tryCatch(
|
||||||
|
pg.manyOrNone<{ name: string }>(`SELECT interests.name
|
||||||
|
FROM interests`)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
log('Error getting profile options', result.error)
|
||||||
|
throw new APIError(500, 'Error getting profile options')
|
||||||
|
}
|
||||||
|
|
||||||
|
const names = result.data.map(row => row.name)
|
||||||
|
return {names}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
import { APIHandler } from './helpers/endpoint'
|
import {APIError, APIHandler} from './helpers/endpoint'
|
||||||
import {
|
import {PrivateMessageChannel,} from 'common/supabase/private-messages'
|
||||||
convertPrivateChatMessage,
|
import {groupBy, mapValues} from 'lodash'
|
||||||
PrivateMessageChannel,
|
import {convertPrivateChatMessage} from "shared/supabase/messages";
|
||||||
} from 'common/supabase/private-messages'
|
import {tryCatch} from "common/util/try-catch";
|
||||||
import { groupBy, mapValues } from 'lodash'
|
|
||||||
|
|
||||||
export const getChannelMemberships: APIHandler<
|
export const getChannelMemberships: APIHandler<
|
||||||
'get-channel-memberships'
|
'get-channel-memberships'
|
||||||
> = async (props, auth) => {
|
> = async (props, auth) => {
|
||||||
const pg = createSupabaseDirectClient()
|
const pg = createSupabaseDirectClient()
|
||||||
const { channelId, lastUpdatedTime, createdTime, limit } = props
|
const {channelId, lastUpdatedTime, createdTime, limit} = props
|
||||||
|
|
||||||
let channels: PrivateMessageChannel[]
|
let channels: PrivateMessageChannel[]
|
||||||
const convertRow = (r: any) => ({
|
const convertRow = (r: any) => ({
|
||||||
@@ -24,55 +23,56 @@ export const getChannelMemberships: APIHandler<
|
|||||||
channels = await pg.map(
|
channels = await pg.map(
|
||||||
`select channel_id, notify_after_time, pumcm.created_time, last_updated_time
|
`select channel_id, notify_after_time, pumcm.created_time, last_updated_time
|
||||||
from private_user_message_channel_members pumcm
|
from private_user_message_channel_members pumcm
|
||||||
join private_user_message_channels pumc on pumc.id= pumcm.channel_id
|
join private_user_message_channels pumc on pumc.id = pumcm.channel_id
|
||||||
where user_id = $1
|
where user_id = $1
|
||||||
and channel_id = $2
|
and channel_id = $2
|
||||||
limit $3
|
limit $3
|
||||||
`,
|
`,
|
||||||
[auth.uid, channelId, limit],
|
[auth.uid, channelId, limit],
|
||||||
convertRow
|
convertRow
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
channels = await pg.map(
|
channels = await pg.map(
|
||||||
`with latest_channels as (
|
`with latest_channels as (select distinct on (pumc.id) pumc.id as channel_id,
|
||||||
select distinct on (pumc.id) pumc.id as channel_id, notify_after_time, pumc.created_time,
|
notify_after_time,
|
||||||
(select created_time
|
pumc.created_time,
|
||||||
from private_user_messages
|
(select created_time
|
||||||
where channel_id = pumc.id
|
from private_user_messages
|
||||||
and visibility != 'system_status'
|
where channel_id = pumc.id
|
||||||
and user_id != $1
|
and visibility != 'system_status'
|
||||||
order by created_time desc
|
and user_id != $1
|
||||||
limit 1) as last_updated_time, -- last_updated_time is the last possible unseen message time
|
order by created_time desc
|
||||||
pumc.last_updated_time as last_updated_channel_time -- last_updated_channel_time is the last time the channel was updated
|
limit 1) as last_updated_time, -- last_updated_time is the last possible unseen message time
|
||||||
from private_user_message_channels pumc
|
pumc.last_updated_time as last_updated_channel_time -- last_updated_channel_time is the last time the channel was updated
|
||||||
join private_user_message_channel_members pumcm on pumcm.channel_id = pumc.id
|
from private_user_message_channels pumc
|
||||||
inner join private_user_messages pum on pumc.id = pum.channel_id
|
join private_user_message_channel_members pumcm on pumcm.channel_id = pumc.id
|
||||||
and (pum.visibility != 'introduction' or pum.user_id != $1)
|
inner join private_user_messages pum on pumc.id = pum.channel_id
|
||||||
where pumcm.user_id = $1
|
and (pum.visibility != 'introduction' or pum.user_id != $1)
|
||||||
and not status = 'left'
|
where pumcm.user_id = $1
|
||||||
and ($2 is null or pumcm.created_time > $2)
|
and not status = 'left'
|
||||||
and ($4 is null or pumc.last_updated_time > $4)
|
and ($2 is null or pumcm.created_time > $2)
|
||||||
order by pumc.id, pumc.last_updated_time desc
|
and ($4 is null or pumc.last_updated_time > $4)
|
||||||
)
|
order by pumc.id, pumc.last_updated_time desc)
|
||||||
select * from latest_channels
|
select *
|
||||||
|
from latest_channels
|
||||||
order by last_updated_channel_time desc
|
order by last_updated_channel_time desc
|
||||||
limit $3
|
limit $3
|
||||||
`,
|
`,
|
||||||
[auth.uid, createdTime ?? null, limit, lastUpdatedTime ?? null],
|
[auth.uid, createdTime ?? null, limit, lastUpdatedTime ?? null],
|
||||||
convertRow
|
convertRow
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!channels || channels.length === 0)
|
if (!channels || channels.length === 0)
|
||||||
return { channels: [], memberIdsByChannelId: {} }
|
return {channels: [], memberIdsByChannelId: {}}
|
||||||
const channelIds = channels.map((c) => c.channel_id)
|
const channelIds = channels.map((c) => c.channel_id)
|
||||||
|
|
||||||
const members = await pg.map(
|
const members = await pg.map(
|
||||||
`select channel_id, user_id
|
`select channel_id, user_id
|
||||||
from private_user_message_channel_members
|
from private_user_message_channel_members
|
||||||
where not user_id = $1
|
where not user_id = $1
|
||||||
and channel_id in ($2:list)
|
and channel_id in ($2:list)
|
||||||
and not status = 'left'
|
and not status = 'left'
|
||||||
`,
|
`,
|
||||||
[auth.uid, channelIds],
|
[auth.uid, channelIds],
|
||||||
(r) => ({
|
(r) => ({
|
||||||
channel_id: r.channel_id as number,
|
channel_id: r.channel_id as number,
|
||||||
@@ -91,39 +91,56 @@ export const getChannelMemberships: APIHandler<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getChannelMessages: APIHandler<'get-channel-messages'> = async (
|
export const getChannelMessagesEndpoint: APIHandler<'get-channel-messages'> = async (
|
||||||
props,
|
props,
|
||||||
auth
|
auth
|
||||||
) => {
|
) => {
|
||||||
|
const userId = auth.uid
|
||||||
|
return await getChannelMessages({...props, userId})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getChannelMessages(props: {
|
||||||
|
channelId: number;
|
||||||
|
limit: number;
|
||||||
|
id?: number | undefined;
|
||||||
|
userId: string;
|
||||||
|
}) {
|
||||||
|
// console.log('initial message request', props)
|
||||||
|
const {channelId, limit, id, userId} = props
|
||||||
const pg = createSupabaseDirectClient()
|
const pg = createSupabaseDirectClient()
|
||||||
const { channelId, limit, id } = props
|
const {data, error} = await tryCatch(pg.map(
|
||||||
return await pg.map(
|
|
||||||
`select *, created_time as created_time_ts
|
`select *, created_time as created_time_ts
|
||||||
from private_user_messages
|
from private_user_messages
|
||||||
where channel_id = $1
|
where channel_id = $1
|
||||||
and exists (select 1 from private_user_message_channel_members pumcm
|
and exists (select 1
|
||||||
where pumcm.user_id = $2
|
from private_user_message_channel_members pumcm
|
||||||
and pumcm.channel_id = $1
|
where pumcm.user_id = $2
|
||||||
)
|
and pumcm.channel_id = $1)
|
||||||
and ($4 is null or id > $4)
|
and ($4 is null or id > $4)
|
||||||
and not visibility = 'system_status'
|
and not visibility = 'system_status'
|
||||||
order by created_time desc
|
order by created_time desc
|
||||||
limit $3
|
limit $3
|
||||||
`,
|
`,
|
||||||
[channelId, auth.uid, limit, id],
|
[channelId, userId, limit, id],
|
||||||
convertPrivateChatMessage
|
convertPrivateChatMessage
|
||||||
)
|
))
|
||||||
|
if (error) {
|
||||||
|
console.error(error)
|
||||||
|
throw new APIError(401, 'Error getting messages')
|
||||||
|
}
|
||||||
|
// console.log('final messages', data)
|
||||||
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getLastSeenChannelTime: APIHandler<
|
export const getLastSeenChannelTime: APIHandler<
|
||||||
'get-channel-seen-time'
|
'get-channel-seen-time'
|
||||||
> = async (props, auth) => {
|
> = async (props, auth) => {
|
||||||
const pg = createSupabaseDirectClient()
|
const pg = createSupabaseDirectClient()
|
||||||
const { channelIds } = props
|
const {channelIds} = props
|
||||||
const unseens = await pg.map(
|
const unseens = await pg.map(
|
||||||
`select distinct on (channel_id) channel_id, created_time
|
`select distinct on (channel_id) channel_id, created_time
|
||||||
from private_user_seen_message_channels
|
from private_user_seen_message_channels
|
||||||
where channel_id = any($1)
|
where channel_id = any ($1)
|
||||||
and user_id = $2
|
and user_id = $2
|
||||||
order by channel_id, created_time desc
|
order by channel_id, created_time desc
|
||||||
`,
|
`,
|
||||||
@@ -137,11 +154,11 @@ export const setChannelLastSeenTime: APIHandler<
|
|||||||
'set-channel-seen-time'
|
'set-channel-seen-time'
|
||||||
> = async (props, auth) => {
|
> = async (props, auth) => {
|
||||||
const pg = createSupabaseDirectClient()
|
const pg = createSupabaseDirectClient()
|
||||||
const { channelId } = props
|
const {channelId} = props
|
||||||
await pg.none(
|
await pg.none(
|
||||||
`insert into private_user_seen_message_channels (user_id, channel_id)
|
`insert into private_user_seen_message_channels (user_id, channel_id)
|
||||||
values ($1, $2)
|
values ($1, $2)
|
||||||
`,
|
`,
|
||||||
[auth.uid, channelId]
|
[auth.uid, channelId]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ export const getProfileAnswers: APIHandler<'get-profile-answers'> = async (
|
|||||||
const { userId } = props
|
const { userId } = props
|
||||||
const pg = createSupabaseDirectClient()
|
const pg = createSupabaseDirectClient()
|
||||||
|
|
||||||
const answers = await pg.manyOrNone<Row<'love_compatibility_answers'>>(
|
const answers = await pg.manyOrNone<Row<'compatibility_answers'>>(
|
||||||
`select * from love_compatibility_answers
|
`select * from compatibility_answers
|
||||||
where
|
where
|
||||||
creator_id = $1
|
creator_id = $1
|
||||||
order by created_time desc
|
order by created_time desc
|
||||||
|
|||||||
@@ -1,104 +1,158 @@
|
|||||||
import {type APIHandler} from 'api/helpers/endpoint'
|
import {type APIHandler} from 'api/helpers/endpoint'
|
||||||
import {convertRow} from 'shared/love/supabase'
|
import {convertRow} from 'shared/profiles/supabase'
|
||||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
import {createSupabaseDirectClient, pgp} from 'shared/supabase/init'
|
||||||
import {from, join, limit, orderBy, renderSql, select, where,} from 'shared/supabase/sql-builder'
|
import {from, join, leftJoin, limit, orderBy, renderSql, select, where,} from 'shared/supabase/sql-builder'
|
||||||
import {getCompatibleProfiles} from 'api/compatible-profiles'
|
import {MIN_BIO_LENGTH} from "common/constants";
|
||||||
import {intersection} from 'lodash'
|
import {compact} from "lodash";
|
||||||
import {MAX_INT, MIN_INT} from "common/constants";
|
import {OptionTableKey} from "common/profiles/constants";
|
||||||
|
|
||||||
export type profileQueryType = {
|
export type profileQueryType = {
|
||||||
limit?: number | undefined,
|
limit?: number | undefined,
|
||||||
after?: string | undefined,
|
after?: string | undefined,
|
||||||
// Search and filter parameters
|
// Search and filter parameters
|
||||||
name?: string | undefined,
|
name?: string | undefined,
|
||||||
genders?: String[] | undefined,
|
genders?: string[] | undefined,
|
||||||
pref_gender?: String[] | undefined,
|
education_levels?: string[] | undefined,
|
||||||
|
pref_gender?: string[] | undefined,
|
||||||
pref_age_min?: number | undefined,
|
pref_age_min?: number | undefined,
|
||||||
pref_age_max?: number | undefined,
|
pref_age_max?: number | undefined,
|
||||||
pref_relation_styles?: String[] | undefined,
|
drinks_min?: number | undefined,
|
||||||
|
drinks_max?: number | undefined,
|
||||||
|
pref_relation_styles?: string[] | undefined,
|
||||||
|
pref_romantic_styles?: string[] | undefined,
|
||||||
|
diet?: string[] | undefined,
|
||||||
|
political_beliefs?: string[] | undefined,
|
||||||
|
mbti?: string[] | undefined,
|
||||||
|
relationship_status?: string[] | undefined,
|
||||||
|
languages?: string[] | undefined,
|
||||||
|
religion?: string[] | undefined,
|
||||||
wants_kids_strength?: number | undefined,
|
wants_kids_strength?: number | undefined,
|
||||||
has_kids?: number | undefined,
|
has_kids?: number | undefined,
|
||||||
is_smoker?: boolean | undefined,
|
is_smoker?: boolean | undefined,
|
||||||
geodbCityIds?: String[] | undefined,
|
shortBio?: boolean | undefined,
|
||||||
|
geodbCityIds?: string[] | undefined,
|
||||||
|
lat?: number | undefined,
|
||||||
|
lon?: number | undefined,
|
||||||
|
radius?: number | undefined,
|
||||||
compatibleWithUserId?: string | undefined,
|
compatibleWithUserId?: string | undefined,
|
||||||
skipId?: string | undefined,
|
skipId?: string | undefined,
|
||||||
orderBy?: string | undefined,
|
orderBy?: string | undefined,
|
||||||
lastModificationWithin?: string | undefined,
|
lastModificationWithin?: string | undefined,
|
||||||
|
} & {
|
||||||
|
[K in OptionTableKey]?: string[] | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// const userActivityColumns = ['last_online_time']
|
||||||
|
|
||||||
|
|
||||||
export const loadProfiles = async (props: profileQueryType) => {
|
export const loadProfiles = async (props: profileQueryType) => {
|
||||||
const pg = createSupabaseDirectClient()
|
const pg = createSupabaseDirectClient()
|
||||||
console.log(props)
|
console.debug('loadProfiles', props)
|
||||||
const {
|
const {
|
||||||
limit: limitParam,
|
limit: limitParam,
|
||||||
after,
|
after,
|
||||||
name,
|
name,
|
||||||
genders,
|
genders,
|
||||||
|
education_levels,
|
||||||
pref_gender,
|
pref_gender,
|
||||||
pref_age_min,
|
pref_age_min,
|
||||||
pref_age_max,
|
pref_age_max,
|
||||||
|
drinks_min,
|
||||||
|
drinks_max,
|
||||||
pref_relation_styles,
|
pref_relation_styles,
|
||||||
|
pref_romantic_styles,
|
||||||
|
diet,
|
||||||
|
political_beliefs,
|
||||||
|
mbti,
|
||||||
|
relationship_status,
|
||||||
|
languages,
|
||||||
|
religion,
|
||||||
wants_kids_strength,
|
wants_kids_strength,
|
||||||
has_kids,
|
has_kids,
|
||||||
|
interests,
|
||||||
|
causes,
|
||||||
|
work,
|
||||||
is_smoker,
|
is_smoker,
|
||||||
|
shortBio,
|
||||||
geodbCityIds,
|
geodbCityIds,
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
radius,
|
||||||
compatibleWithUserId,
|
compatibleWithUserId,
|
||||||
orderBy: orderByParam = 'created_time',
|
orderBy: orderByParam = 'created_time',
|
||||||
lastModificationWithin,
|
lastModificationWithin,
|
||||||
skipId,
|
skipId,
|
||||||
} = props
|
} = props
|
||||||
|
|
||||||
|
const filterLocation = lat && lon && radius
|
||||||
|
|
||||||
const keywords = name ? name.split(",").map(q => q.trim()).filter(Boolean) : []
|
const keywords = name ? name.split(",").map(q => q.trim()).filter(Boolean) : []
|
||||||
// console.debug('keywords:', keywords)
|
// console.debug('keywords:', keywords)
|
||||||
|
|
||||||
// compatibility. TODO: do this in sql
|
if (orderByParam === 'compatibility_score' && !compatibleWithUserId) {
|
||||||
if (orderByParam === 'compatibility_score') {
|
console.error('Incompatible with user ID')
|
||||||
if (!compatibleWithUserId) {
|
throw Error('Incompatible with user ID')
|
||||||
console.error('Incompatible with user ID')
|
|
||||||
throw Error('Incompatible with user ID')
|
|
||||||
}
|
|
||||||
|
|
||||||
const {compatibleProfiles} = await getCompatibleProfiles(compatibleWithUserId)
|
|
||||||
const profiles = compatibleProfiles.filter(
|
|
||||||
(l) =>
|
|
||||||
(!name || l.user.name.toLowerCase().includes(name.toLowerCase())) &&
|
|
||||||
(!genders || genders.includes(l.gender)) &&
|
|
||||||
(!pref_gender || intersection(pref_gender, l.pref_gender).length) &&
|
|
||||||
(!pref_age_min || (l.age ?? MAX_INT) >= pref_age_min) &&
|
|
||||||
(!pref_age_max || (l.age ?? MIN_INT) <= pref_age_max) &&
|
|
||||||
(!pref_relation_styles ||
|
|
||||||
intersection(pref_relation_styles, l.pref_relation_styles).length) &&
|
|
||||||
(!wants_kids_strength ||
|
|
||||||
wants_kids_strength == -1 ||
|
|
||||||
(wants_kids_strength >= 2
|
|
||||||
? l.wants_kids_strength >= wants_kids_strength
|
|
||||||
: l.wants_kids_strength <= wants_kids_strength)) &&
|
|
||||||
(has_kids == undefined ||
|
|
||||||
has_kids == -1 ||
|
|
||||||
(has_kids == 0 && !l.has_kids) ||
|
|
||||||
(l.has_kids && l.has_kids > 0)) &&
|
|
||||||
(!is_smoker || l.is_smoker === is_smoker) &&
|
|
||||||
(l.id.toString() != skipId) &&
|
|
||||||
(!geodbCityIds ||
|
|
||||||
(l.geodb_city_id && geodbCityIds.includes(l.geodb_city_id)))
|
|
||||||
)
|
|
||||||
|
|
||||||
const cursor = after
|
|
||||||
? profiles.findIndex((l) => l.id.toString() === after) + 1
|
|
||||||
: 0
|
|
||||||
console.log(cursor)
|
|
||||||
|
|
||||||
if (limitParam) return profiles.slice(cursor, cursor + limitParam)
|
|
||||||
|
|
||||||
return profiles
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = renderSql(
|
const tablePrefix = orderByParam === 'compatibility_score'
|
||||||
select('profiles.*, name, username, users.data as user'),
|
? 'compatibility_scores'
|
||||||
|
: orderByParam === 'last_online_time'
|
||||||
|
? 'user_activity'
|
||||||
|
: 'profiles'
|
||||||
|
|
||||||
|
const userActivityJoin = 'user_activity on user_activity.user_id = profiles.user_id'
|
||||||
|
|
||||||
|
// Pre-aggregated interests per profile
|
||||||
|
function getManyToManyJoin(label: OptionTableKey) {
|
||||||
|
return `(
|
||||||
|
SELECT
|
||||||
|
profile_${label}.profile_id,
|
||||||
|
ARRAY_AGG(${label}.name ORDER BY ${label}.name) AS ${label}
|
||||||
|
FROM profile_${label}
|
||||||
|
JOIN ${label} ON ${label}.id = profile_${label}.option_id
|
||||||
|
GROUP BY profile_${label}.profile_id
|
||||||
|
) profile_${label} ON profile_${label}.profile_id = profiles.id`
|
||||||
|
}
|
||||||
|
const interestsJoin = getManyToManyJoin('interests')
|
||||||
|
const causesJoin = getManyToManyJoin('causes')
|
||||||
|
const workJoin = getManyToManyJoin('work')
|
||||||
|
|
||||||
|
const compatibilityScoreJoin = pgp.as.format(`compatibility_scores cs on (cs.user_id_1 = LEAST(profiles.user_id, $(compatibleWithUserId)) and cs.user_id_2 = GREATEST(profiles.user_id, $(compatibleWithUserId)))`, {compatibleWithUserId})
|
||||||
|
|
||||||
|
const joins = [
|
||||||
|
orderByParam === 'last_online_time' && leftJoin(userActivityJoin),
|
||||||
|
orderByParam === 'compatibility_score' && compatibleWithUserId && join(compatibilityScoreJoin),
|
||||||
|
interests && leftJoin(interestsJoin),
|
||||||
|
causes && leftJoin(causesJoin),
|
||||||
|
work && leftJoin(workJoin),
|
||||||
|
]
|
||||||
|
|
||||||
|
const _orderBy = orderByParam === 'compatibility_score' ? 'cs.score' : `${tablePrefix}.${orderByParam}`
|
||||||
|
const afterFilter = renderSql(
|
||||||
|
select(_orderBy),
|
||||||
|
from('profiles'),
|
||||||
|
...joins,
|
||||||
|
where('profiles.id = $(after)', {after}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const tableSelection = compact([
|
||||||
from('profiles'),
|
from('profiles'),
|
||||||
join('users on users.id = profiles.user_id'),
|
join('users on users.id = profiles.user_id'),
|
||||||
|
...joins,
|
||||||
|
])
|
||||||
|
|
||||||
|
function getManyToManyClause(label: OptionTableKey) {
|
||||||
|
return `EXISTS (
|
||||||
|
SELECT 1 FROM profile_${label}
|
||||||
|
JOIN ${label} ON ${label}.id = profile_${label}.option_id
|
||||||
|
WHERE profile_${label}.profile_id = profiles.id
|
||||||
|
AND ${label}.name = ANY (ARRAY[$(values)])
|
||||||
|
)`
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = [
|
||||||
where('looking_for_matches = true'),
|
where('looking_for_matches = true'),
|
||||||
|
where(`profiles.disabled != true`),
|
||||||
// where(`pinned_url is not null and pinned_url != ''`),
|
// where(`pinned_url is not null and pinned_url != ''`),
|
||||||
where(
|
where(
|
||||||
`(data->>'isBannedFromPosting' != 'true' or data->>'isBannedFromPosting' is null)`
|
`(data->>'isBannedFromPosting' != 'true' or data->>'isBannedFromPosting' is null)`
|
||||||
@@ -106,14 +160,18 @@ export const loadProfiles = async (props: profileQueryType) => {
|
|||||||
where(`data->>'userDeleted' != 'true' or data->>'userDeleted' is null`),
|
where(`data->>'userDeleted' != 'true' or data->>'userDeleted' is null`),
|
||||||
|
|
||||||
...keywords.map(word => where(
|
...keywords.map(word => where(
|
||||||
`lower(users.name) ilike '%' || lower($(word)) || '%' or lower(bio::text) ilike '%' || lower($(word)) || '%'`,
|
`lower(users.name) ilike '%' || lower($(word)) || '%' or lower(bio::text) ilike '%' || lower($(word)) || '%' or bio_tsv @@ phraseto_tsquery('english', $(word))`,
|
||||||
{word}
|
{word}
|
||||||
)),
|
)),
|
||||||
|
|
||||||
genders?.length && where(`gender = ANY($(gender))`, {gender: genders}),
|
genders?.length && where(`gender = ANY($(genders))`, {genders}),
|
||||||
|
|
||||||
|
education_levels?.length && where(`education_level = ANY($(education_levels))`, {education_levels}),
|
||||||
|
|
||||||
|
mbti?.length && where(`mbti = ANY($(mbti))`, {mbti}),
|
||||||
|
|
||||||
pref_gender?.length &&
|
pref_gender?.length &&
|
||||||
where(`pref_gender && $(pref_gender)`, {pref_gender}),
|
where(`pref_gender is NULL or pref_gender = '{}' OR pref_gender && $(pref_gender)`, {pref_gender}),
|
||||||
|
|
||||||
pref_age_min &&
|
pref_age_min &&
|
||||||
where(`age >= $(pref_age_min) or age is null`, {pref_age_min}),
|
where(`age >= $(pref_age_min) or age is null`, {pref_age_min}),
|
||||||
@@ -121,53 +179,142 @@ export const loadProfiles = async (props: profileQueryType) => {
|
|||||||
pref_age_max &&
|
pref_age_max &&
|
||||||
where(`age <= $(pref_age_max) or age is null`, {pref_age_max}),
|
where(`age <= $(pref_age_max) or age is null`, {pref_age_max}),
|
||||||
|
|
||||||
|
drinks_min &&
|
||||||
|
where(`drinks_per_month >= $(drinks_min) or drinks_per_month is null`, {drinks_min}),
|
||||||
|
|
||||||
|
drinks_max &&
|
||||||
|
where(`drinks_per_month <= $(drinks_max) or drinks_per_month is null`, {drinks_max}),
|
||||||
|
|
||||||
pref_relation_styles?.length &&
|
pref_relation_styles?.length &&
|
||||||
where(
|
where(
|
||||||
`pref_relation_styles IS NULL OR pref_relation_styles = '{}' OR pref_relation_styles && $(pref_relation_styles)`,
|
`pref_relation_styles IS NULL OR pref_relation_styles = '{}' OR pref_relation_styles && $(pref_relation_styles)`,
|
||||||
{ pref_relation_styles }
|
{pref_relation_styles}
|
||||||
),
|
),
|
||||||
|
|
||||||
|
pref_romantic_styles?.length &&
|
||||||
|
where(
|
||||||
|
`pref_romantic_styles IS NULL OR pref_romantic_styles = '{}' OR pref_romantic_styles && $(pref_romantic_styles)`,
|
||||||
|
{pref_romantic_styles}
|
||||||
|
),
|
||||||
|
|
||||||
|
diet?.length &&
|
||||||
|
where(
|
||||||
|
`diet IS NULL OR diet = '{}' OR diet && $(diet)`,
|
||||||
|
{diet}
|
||||||
|
),
|
||||||
|
|
||||||
|
political_beliefs?.length &&
|
||||||
|
where(
|
||||||
|
`political_beliefs IS NULL OR political_beliefs = '{}' OR political_beliefs && $(political_beliefs)`,
|
||||||
|
{political_beliefs}
|
||||||
|
),
|
||||||
|
|
||||||
|
relationship_status?.length &&
|
||||||
|
where(
|
||||||
|
`relationship_status IS NULL OR relationship_status = '{}' OR relationship_status && $(relationship_status)`,
|
||||||
|
{relationship_status}
|
||||||
|
),
|
||||||
|
|
||||||
|
languages?.length &&
|
||||||
|
where(
|
||||||
|
`languages && $(languages)`,
|
||||||
|
{languages}
|
||||||
|
),
|
||||||
|
|
||||||
|
religion?.length &&
|
||||||
|
where(
|
||||||
|
`religion IS NULL OR religion = '{}' OR religion && $(religion)`,
|
||||||
|
{religion}
|
||||||
|
),
|
||||||
|
|
||||||
|
interests?.length && where(getManyToManyClause('interests'), {values: interests}),
|
||||||
|
|
||||||
|
causes?.length && where(getManyToManyClause('causes'), {values: causes}),
|
||||||
|
|
||||||
|
work?.length && where(getManyToManyClause('work'), {values: work}),
|
||||||
|
|
||||||
!!wants_kids_strength &&
|
!!wants_kids_strength &&
|
||||||
wants_kids_strength !== -1 &&
|
wants_kids_strength !== -1 &&
|
||||||
where(
|
where(
|
||||||
wants_kids_strength >= 2
|
'wants_kids_strength = -1 OR wants_kids_strength IS NULL OR ' + (wants_kids_strength >= 2 ? `wants_kids_strength >= $(wants_kids_strength)` : `wants_kids_strength <= $(wants_kids_strength)`),
|
||||||
? `wants_kids_strength >= $(wants_kids_strength)`
|
|
||||||
: `wants_kids_strength <= $(wants_kids_strength)`,
|
|
||||||
{wants_kids_strength}
|
{wants_kids_strength}
|
||||||
),
|
),
|
||||||
|
|
||||||
has_kids === 0 && where(`has_kids IS NULL OR has_kids = 0`),
|
has_kids === 0 && where(`has_kids IS NULL OR has_kids = 0`),
|
||||||
has_kids && has_kids > 0 && where(`has_kids > 0`),
|
has_kids && has_kids > 0 && where(`has_kids > 0`),
|
||||||
|
|
||||||
is_smoker !== undefined && where(`is_smoker = $(is_smoker)`, {is_smoker}),
|
is_smoker !== undefined && (
|
||||||
|
where(
|
||||||
|
(is_smoker ? '' : 'is_smoker IS NULL OR ') + // smokers are rare, so we don't include the people who didn't answer if we're looking for smokers
|
||||||
|
`is_smoker = $(is_smoker)`, {is_smoker}
|
||||||
|
)
|
||||||
|
),
|
||||||
|
|
||||||
geodbCityIds?.length &&
|
geodbCityIds?.length &&
|
||||||
where(`geodb_city_id = ANY($(geodbCityIds))`, {geodbCityIds}),
|
where(`geodb_city_id = ANY($(geodbCityIds))`, {geodbCityIds}),
|
||||||
|
|
||||||
skipId && where(`user_id != $(skipId)`, {skipId}),
|
// miles par degree of lat: earth's radius (3950 miles) * pi / 180 = 69.0
|
||||||
|
filterLocation && where(`
|
||||||
|
city_latitude BETWEEN $(target_lat) - ($(radius) / 69.0)
|
||||||
|
AND $(target_lat) + ($(radius) / 69.0)
|
||||||
|
AND city_longitude BETWEEN $(target_lon) - ($(radius) / (69.0 * COS(RADIANS($(target_lat)))))
|
||||||
|
AND $(target_lon) + ($(radius) / (69.0 * COS(RADIANS($(target_lat)))))
|
||||||
|
AND SQRT(
|
||||||
|
POWER(city_latitude - $(target_lat), 2)
|
||||||
|
+ POWER((city_longitude - $(target_lon)) * COS(RADIANS($(target_lat))), 2)
|
||||||
|
) <= $(radius) / 69.0
|
||||||
|
`, {target_lat: lat, target_lon: lon, radius}),
|
||||||
|
|
||||||
orderBy(`${orderByParam} desc`),
|
skipId && where(`profiles.user_id != $(skipId)`, {skipId}),
|
||||||
after &&
|
|
||||||
where(
|
!shortBio && where(`bio_length >= ${MIN_BIO_LENGTH}`, {MIN_BIO_LENGTH}),
|
||||||
`profiles.${orderByParam} < (select profiles.${orderByParam} from profiles where id = $(after))`,
|
|
||||||
{after}
|
|
||||||
),
|
|
||||||
|
|
||||||
lastModificationWithin && where(`last_modification_time >= NOW() - INTERVAL $(lastModificationWithin)`, {lastModificationWithin}),
|
lastModificationWithin && where(`last_modification_time >= NOW() - INTERVAL $(lastModificationWithin)`, {lastModificationWithin}),
|
||||||
|
]
|
||||||
|
|
||||||
limitParam && limit(limitParam)
|
let selectCols = 'profiles.*, users.name, users.username, users.data as user'
|
||||||
|
if (orderByParam === 'compatibility_score') {
|
||||||
|
selectCols += ', cs.score as compatibility_score'
|
||||||
|
} else if (orderByParam === 'last_online_time') {
|
||||||
|
selectCols += ', user_activity.last_online_time'
|
||||||
|
}
|
||||||
|
if (interests) selectCols += `, COALESCE(profile_interests.interests, '{}') AS interests`
|
||||||
|
if (causes) selectCols += `, COALESCE(profile_causes.causes, '{}') AS causes`
|
||||||
|
if (work) selectCols += `, COALESCE(profile_work.work, '{}') AS work`
|
||||||
|
|
||||||
|
const query = renderSql(
|
||||||
|
select(selectCols),
|
||||||
|
...tableSelection,
|
||||||
|
...filters,
|
||||||
|
orderBy(`${_orderBy} DESC`),
|
||||||
|
after && where(`${_orderBy} < (${afterFilter})`),
|
||||||
|
limitParam && limit(limitParam),
|
||||||
)
|
)
|
||||||
|
|
||||||
// console.log('query:', query)
|
// console.debug('query:', query)
|
||||||
|
|
||||||
return await pg.map(query, [], convertRow)
|
const profiles = await pg.map(query, [], convertRow)
|
||||||
|
|
||||||
|
// console.debug('profiles:', profiles)
|
||||||
|
|
||||||
|
const countQuery = renderSql(
|
||||||
|
select(`count(*) as count`),
|
||||||
|
...tableSelection,
|
||||||
|
...filters,
|
||||||
|
)
|
||||||
|
|
||||||
|
const count = await pg.one<number>(countQuery, [], (r) => Number(r.count))
|
||||||
|
|
||||||
|
return {profiles, count}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getProfiles: APIHandler<'get-profiles'> = async (props, _auth) => {
|
export const getProfiles: APIHandler<'get-profiles'> = async (props, auth) => {
|
||||||
try {
|
try {
|
||||||
const profiles = await loadProfiles(props)
|
if (!props.skipId) props.skipId = auth.uid
|
||||||
return {status: 'success', profiles: profiles}
|
const {profiles, count} = await loadProfiles(props)
|
||||||
} catch {
|
return {status: 'success', profiles: profiles, count: count}
|
||||||
return {status: 'fail', profiles: []}
|
} catch (error) {
|
||||||
|
console.log(error)
|
||||||
|
return {status: 'fail', profiles: [], count: 0}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { sign } from 'jsonwebtoken'
|
import {sign} from 'jsonwebtoken'
|
||||||
import { APIError, APIHandler } from './helpers/endpoint'
|
import {APIError, APIHandler} from './helpers/endpoint'
|
||||||
import { DEV_CONFIG } from 'common/envs/dev'
|
import {ENV_CONFIG} from "common/envs/constants";
|
||||||
import { PROD_CONFIG } from 'common/envs/prod'
|
|
||||||
import { isProd } from 'shared/utils'
|
|
||||||
|
|
||||||
export const getSupabaseToken: APIHandler<'get-supabase-token'> = async (
|
export const getSupabaseToken: APIHandler<'get-supabase-token'> = async (
|
||||||
_,
|
_,
|
||||||
@@ -12,21 +10,17 @@ export const getSupabaseToken: APIHandler<'get-supabase-token'> = async (
|
|||||||
if (jwtSecret == null) {
|
if (jwtSecret == null) {
|
||||||
throw new APIError(500, "No SUPABASE_JWT_SECRET; couldn't sign token.")
|
throw new APIError(500, "No SUPABASE_JWT_SECRET; couldn't sign token.")
|
||||||
}
|
}
|
||||||
const instanceId = isProd()
|
const instanceId = ENV_CONFIG.supabaseInstanceId
|
||||||
? PROD_CONFIG.supabaseInstanceId
|
|
||||||
: DEV_CONFIG.supabaseInstanceId
|
|
||||||
if (!instanceId) {
|
if (!instanceId) {
|
||||||
throw new APIError(500, 'No Supabase instance ID in config.')
|
throw new APIError(500, 'No Supabase instance ID in config.')
|
||||||
}
|
}
|
||||||
const payload = { role: 'anon' } // postgres role
|
const payload = {role: 'anon'} // postgres role
|
||||||
return {
|
return {
|
||||||
jwt: sign(payload, jwtSecret, {
|
jwt: sign(payload, jwtSecret, {
|
||||||
algorithm: 'HS256', // same as what supabase uses for its auth tokens
|
algorithm: 'HS256', // same as what supabase uses for its auth tokens
|
||||||
expiresIn: '1d',
|
expiresIn: '1d',
|
||||||
audience: instanceId,
|
audience: instanceId,
|
||||||
issuer: isProd()
|
issuer: ENV_CONFIG.firebaseConfig.projectId,
|
||||||
? PROD_CONFIG.firebaseConfig.projectId
|
|
||||||
: DEV_CONFIG.firebaseConfig.projectId,
|
|
||||||
subject: auth.uid,
|
subject: auth.uid,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { toUserAPIResponse } from 'common/api/user-types'
|
import { toUserAPIResponse } from 'common/api/user-types'
|
||||||
import { convertUser, displayUserColumns } from 'common/supabase/users'
|
import { convertUser } from 'common/supabase/users'
|
||||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||||
import { APIError } from 'common/api/utils'
|
import { APIError } from 'common/api/utils'
|
||||||
import { removeNullOrUndefinedProps } from 'common/util/object'
|
|
||||||
|
|
||||||
export const getUser = async (props: { id: string } | { username: string }) => {
|
export const getUser = async (props: { id: string } | { username: string }) => {
|
||||||
const pg = createSupabaseDirectClient()
|
const pg = createSupabaseDirectClient()
|
||||||
@@ -17,17 +16,18 @@ export const getUser = async (props: { id: string } | { username: string }) => {
|
|||||||
return toUserAPIResponse(user)
|
return toUserAPIResponse(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getDisplayUser = async (
|
// export const getDisplayUser = async (
|
||||||
props: { id: string } | { username: string }
|
// props: { id: string } | { username: string }
|
||||||
) => {
|
// ) => {
|
||||||
const pg = createSupabaseDirectClient()
|
// console.log('getDisplayUser', props)
|
||||||
const liteUser = await pg.oneOrNone(
|
// const pg = createSupabaseDirectClient()
|
||||||
`select ${displayUserColumns}
|
// const liteUser = await pg.oneOrNone(
|
||||||
from users
|
// `select ${displayUserColumns}
|
||||||
where ${'id' in props ? 'id' : 'username'} = $1`,
|
// from users
|
||||||
['id' in props ? props.id : props.username]
|
// where ${'id' in props ? 'id' : 'username'} = $1`,
|
||||||
)
|
// ['id' in props ? props.id : props.username]
|
||||||
if (!liteUser) throw new APIError(404, 'User not found')
|
// )
|
||||||
|
// if (!liteUser) throw new APIError(404, 'User not found')
|
||||||
return removeNullOrUndefinedProps(liteUser)
|
//
|
||||||
}
|
// return removeNullOrUndefinedProps(liteUser)
|
||||||
|
// }
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const getHasFreeLike = async (userId: string) => {
|
|||||||
const likeGivenToday = await pg.oneOrNone<object>(
|
const likeGivenToday = await pg.oneOrNone<object>(
|
||||||
`
|
`
|
||||||
select 1
|
select 1
|
||||||
from love_likes
|
from profile_likes
|
||||||
where creator_id = $1
|
where creator_id = $1
|
||||||
and created_time at time zone 'UTC' at time zone 'America/Los_Angeles' >= (now() at time zone 'UTC' at time zone 'America/Los_Angeles')::date
|
and created_time at time zone 'UTC' at time zone 'America/Los_Angeles' >= (now() at time zone 'UTC' at time zone 'America/Los_Angeles')::date
|
||||||
and created_time at time zone 'UTC' at time zone 'America/Los_Angeles' < ((now() at time zone 'UTC' at time zone 'America/Los_Angeles')::date + interval '1 day')
|
and created_time at time zone 'UTC' at time zone 'America/Los_Angeles' < ((now() at time zone 'UTC' at time zone 'America/Los_Angeles')::date + interval '1 day')
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { APIHandler } from './helpers/endpoint'
|
import { APIHandler } from './helpers/endpoint'
|
||||||
|
import {git} from './../metadata.json'
|
||||||
|
|
||||||
export const health: APIHandler<'health'> = async (_, auth) => {
|
export const health: APIHandler<'health'> = async (_, auth) => {
|
||||||
return {
|
return {
|
||||||
message: 'Server is working.',
|
message: 'Server is working.',
|
||||||
uid: auth?.uid,
|
uid: auth?.uid,
|
||||||
|
git: git,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,29 @@
|
|||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import { z } from 'zod'
|
import {z} from 'zod'
|
||||||
import { Request, Response, NextFunction } from 'express'
|
import {NextFunction, Request, Response} from 'express'
|
||||||
|
|
||||||
import { PrivateUser } from 'common/user'
|
import {PrivateUser} from 'common/user'
|
||||||
import { APIError } from 'common/api/utils'
|
import {APIError} from 'common/api/utils'
|
||||||
export { APIError } from 'common/api/utils'
|
import {API, APIPath, APIResponseOptionalContinue, APISchema, ValidatedAPIParams,} from 'common/api/schema'
|
||||||
import {
|
import {getPrivateUserByKey, log} from 'shared/utils'
|
||||||
API,
|
|
||||||
APIPath,
|
|
||||||
APIResponseOptionalContinue,
|
|
||||||
APISchema,
|
|
||||||
ValidatedAPIParams,
|
|
||||||
} from 'common/api/schema'
|
|
||||||
import { log } from 'shared/utils'
|
|
||||||
import { getPrivateUserByKey } from 'shared/utils'
|
|
||||||
|
|
||||||
export type Json = Record<string, unknown> | Json[]
|
export {APIError} from 'common/api/utils'
|
||||||
export type JsonHandler<T extends Json> = (
|
|
||||||
req: Request,
|
// export type Json = Record<string, unknown> | Json[]
|
||||||
res: Response
|
// export type JsonHandler<T extends Json> = (
|
||||||
) => Promise<T>
|
// req: Request,
|
||||||
export type AuthedHandler<T extends Json> = (
|
// res: Response
|
||||||
req: Request,
|
// ) => Promise<T>
|
||||||
user: AuthedUser,
|
// export type AuthedHandler<T extends Json> = (
|
||||||
res: Response
|
// req: Request,
|
||||||
) => Promise<T>
|
// user: AuthedUser,
|
||||||
export type MaybeAuthedHandler<T extends Json> = (
|
// res: Response
|
||||||
req: Request,
|
// ) => Promise<T>
|
||||||
user: AuthedUser | undefined,
|
// export type MaybeAuthedHandler<T extends Json> = (
|
||||||
res: Response
|
// req: Request,
|
||||||
) => Promise<T>
|
// user: AuthedUser | undefined,
|
||||||
|
// res: Response
|
||||||
|
// ) => Promise<T>
|
||||||
|
|
||||||
export type AuthedUser = {
|
export type AuthedUser = {
|
||||||
uid: string
|
uid: string
|
||||||
@@ -39,6 +33,29 @@ type JwtCredentials = { kind: 'jwt'; data: admin.auth.DecodedIdToken }
|
|||||||
type KeyCredentials = { kind: 'key'; data: string }
|
type KeyCredentials = { kind: 'key'; data: string }
|
||||||
type Credentials = JwtCredentials | KeyCredentials
|
type Credentials = JwtCredentials | KeyCredentials
|
||||||
|
|
||||||
|
// export async function verifyIdToken(payload: string): Promise<DecodedIdToken> {
|
||||||
|
// TODO: make local dev work without firebase admin SDK setup.
|
||||||
|
// if (IS_LOCAL) {
|
||||||
|
// // Skip real verification locally (to avoid needing to set up admin service account).
|
||||||
|
// return {
|
||||||
|
// aud: "",
|
||||||
|
// auth_time: 0,
|
||||||
|
// email_verified: false,
|
||||||
|
// exp: 0,
|
||||||
|
// firebase: {identities: {}, sign_in_provider: ""},
|
||||||
|
// iat: 0,
|
||||||
|
// iss: "",
|
||||||
|
// phone_number: "",
|
||||||
|
// picture: "",
|
||||||
|
// sub: "",
|
||||||
|
// uid: 'dev-user',
|
||||||
|
// user_id: 'dev-user',
|
||||||
|
// email: 'dev-user@example.com'
|
||||||
|
// };
|
||||||
|
// }
|
||||||
|
// return await admin.auth().verifyIdToken(payload);
|
||||||
|
// }
|
||||||
|
|
||||||
export const parseCredentials = async (req: Request): Promise<Credentials> => {
|
export const parseCredentials = async (req: Request): Promise<Credentials> => {
|
||||||
const auth = admin.auth()
|
const auth = admin.auth()
|
||||||
const authHeader = req.get('Authorization')
|
const authHeader = req.get('Authorization')
|
||||||
@@ -57,14 +74,16 @@ export const parseCredentials = async (req: Request): Promise<Credentials> => {
|
|||||||
throw new APIError(401, 'Firebase JWT payload undefined.')
|
throw new APIError(401, 'Firebase JWT payload undefined.')
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return { kind: 'jwt', data: await auth.verifyIdToken(payload) }
|
return {kind: 'jwt', data: await auth.verifyIdToken(payload)}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
const raw = payload.split(".")[0];
|
||||||
|
console.log("JWT header:", JSON.parse(Buffer.from(raw, "base64").toString()));
|
||||||
// This is somewhat suspicious, so get it into the firebase console
|
// This is somewhat suspicious, so get it into the firebase console
|
||||||
console.error('Error verifying Firebase JWT: ', err, scheme, payload)
|
console.error('Error verifying Firebase JWT: ', err, scheme, payload)
|
||||||
throw new APIError(500, 'Error validating token.')
|
throw new APIError(500, 'Error validating token.')
|
||||||
}
|
}
|
||||||
case 'Key':
|
case 'Key':
|
||||||
return { kind: 'key', data: payload }
|
return {kind: 'key', data: payload}
|
||||||
default:
|
default:
|
||||||
throw new APIError(401, 'Invalid auth scheme; must be "Key" or "Bearer".')
|
throw new APIError(401, 'Invalid auth scheme; must be "Key" or "Bearer".')
|
||||||
}
|
}
|
||||||
@@ -76,7 +95,7 @@ export const lookupUser = async (creds: Credentials): Promise<AuthedUser> => {
|
|||||||
if (typeof creds.data.user_id !== 'string') {
|
if (typeof creds.data.user_id !== 'string') {
|
||||||
throw new APIError(401, 'JWT must contain user ID.')
|
throw new APIError(401, 'JWT must contain user ID.')
|
||||||
}
|
}
|
||||||
return { uid: creds.data.user_id, creds }
|
return {uid: creds.data.user_id, creds}
|
||||||
}
|
}
|
||||||
case 'key': {
|
case 'key': {
|
||||||
const key = creds.data
|
const key = creds.data
|
||||||
@@ -84,7 +103,7 @@ export const lookupUser = async (creds: Credentials): Promise<AuthedUser> => {
|
|||||||
if (!privateUser) {
|
if (!privateUser) {
|
||||||
throw new APIError(401, `No private user exists with API key ${key}.`)
|
throw new APIError(401, `No private user exists with API key ${key}.`)
|
||||||
}
|
}
|
||||||
return { uid: privateUser.id, creds: { privateUser, ...creds } }
|
return {uid: privateUser.id, creds: {privateUser, ...creds}}
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
throw new APIError(401, 'Invalid credential type.')
|
throw new APIError(401, 'Invalid credential type.')
|
||||||
@@ -109,45 +128,45 @@ export const validate = <T extends z.ZodTypeAny>(schema: T, val: unknown) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const jsonEndpoint = <T extends Json>(fn: JsonHandler<T>) => {
|
// export const jsonEndpoint = <T extends Json>(fn: JsonHandler<T>) => {
|
||||||
return async (req: Request, res: Response, next: NextFunction) => {
|
// return async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
// try {
|
||||||
res.status(200).json(await fn(req, res))
|
// res.status(200).json(await fn(req, res))
|
||||||
} catch (e) {
|
// } catch (e) {
|
||||||
next(e)
|
// next(e)
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
export const authEndpoint = <T extends Json>(fn: AuthedHandler<T>) => {
|
// export const authEndpoint = <T extends Json>(fn: AuthedHandler<T>) => {
|
||||||
return async (req: Request, res: Response, next: NextFunction) => {
|
// return async (req: Request, res: Response, next: NextFunction) => {
|
||||||
try {
|
// try {
|
||||||
const authedUser = await lookupUser(await parseCredentials(req))
|
// const authedUser = await lookupUser(await parseCredentials(req))
|
||||||
res.status(200).json(await fn(req, authedUser, res))
|
// res.status(200).json(await fn(req, authedUser, res))
|
||||||
} catch (e) {
|
// } catch (e) {
|
||||||
next(e)
|
// next(e)
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
export const MaybeAuthedEndpoint = <T extends Json>(
|
// export const MaybeAuthedEndpoint = <T extends Json>(
|
||||||
fn: MaybeAuthedHandler<T>
|
// fn: MaybeAuthedHandler<T>
|
||||||
) => {
|
// ) => {
|
||||||
return async (req: Request, res: Response, next: NextFunction) => {
|
// return async (req: Request, res: Response, next: NextFunction) => {
|
||||||
let authUser: AuthedUser | undefined = undefined
|
// let authUser: AuthedUser | undefined = undefined
|
||||||
try {
|
// try {
|
||||||
authUser = await lookupUser(await parseCredentials(req))
|
// authUser = await lookupUser(await parseCredentials(req))
|
||||||
} catch {
|
// } catch {
|
||||||
// it's treated as an anon request
|
// // it's treated as an anon request
|
||||||
}
|
// }
|
||||||
|
//
|
||||||
try {
|
// try {
|
||||||
res.status(200).json(await fn(req, authUser, res))
|
// res.status(200).json(await fn(req, authUser, res))
|
||||||
} catch (e) {
|
// } catch (e) {
|
||||||
next(e)
|
// next(e)
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
|
||||||
export type APIHandler<N extends APIPath> = (
|
export type APIHandler<N extends APIPath> = (
|
||||||
props: ValidatedAPIParams<N>,
|
props: ValidatedAPIParams<N>,
|
||||||
@@ -157,11 +176,63 @@ export type APIHandler<N extends APIPath> = (
|
|||||||
req: Request
|
req: Request
|
||||||
) => Promise<APIResponseOptionalContinue<N>>
|
) => Promise<APIResponseOptionalContinue<N>>
|
||||||
|
|
||||||
|
// Simple in-memory fixed-window rate limiter keyed by auth uid (or IP if unauthenticated)
|
||||||
|
// Not suitable for multi-instance deployments without a shared store, but provides basic protection.
|
||||||
|
// Limits are configurable via env:
|
||||||
|
// API_RATE_LIMIT_PER_MIN_AUTHED
|
||||||
|
// API_RATE_LIMIT_PER_MIN_UNAUTHED
|
||||||
|
// Endpoints can be exempted by adding their name to RATE_LIMIT_EXEMPT (comma-separated)
|
||||||
|
const __rateLimitState: Map<string, { windowStart: number; count: number }> = new Map()
|
||||||
|
|
||||||
|
function getRateLimitConfig() {
|
||||||
|
const authed = Number(process.env.API_RATE_LIMIT_PER_MIN_AUTHED ?? 120)
|
||||||
|
const unAuthed = Number(process.env.API_RATE_LIMIT_PER_MIN_UNAUTHED ?? 120)
|
||||||
|
return {authedLimit: authed, unAuthLimit: unAuthed}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rateLimitKey(name: string, req: Request, auth?: AuthedUser) {
|
||||||
|
if (auth) return `uid:${auth.uid}`
|
||||||
|
// fallback to IP for unauthenticated requests
|
||||||
|
return `ip:${req.ip}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkRateLimit(name: string, req: Request, res: Response, auth?: AuthedUser) {
|
||||||
|
const {authedLimit, unAuthLimit} = getRateLimitConfig()
|
||||||
|
|
||||||
|
const key = rateLimitKey(name, req, auth)
|
||||||
|
const limit = auth ? authedLimit : unAuthLimit
|
||||||
|
const now = Date.now()
|
||||||
|
const windowMs = 60_000
|
||||||
|
const windowStart = Math.floor(now / windowMs) * windowMs
|
||||||
|
|
||||||
|
let state = __rateLimitState.get(key)
|
||||||
|
if (!state || state.windowStart !== windowStart) {
|
||||||
|
state = {windowStart, count: 0}
|
||||||
|
__rateLimitState.set(key, state)
|
||||||
|
}
|
||||||
|
state.count += 1
|
||||||
|
|
||||||
|
const remaining = Math.max(0, limit - state.count)
|
||||||
|
const reset = Math.ceil((state.windowStart + windowMs - now) / 1000)
|
||||||
|
|
||||||
|
// Set standard-ish rate limit headers
|
||||||
|
res.setHeader('X-RateLimit-Limit', String(limit))
|
||||||
|
res.setHeader('X-RateLimit-Remaining', String(Math.max(0, remaining)))
|
||||||
|
res.setHeader('X-RateLimit-Reset', String(reset))
|
||||||
|
|
||||||
|
// console.log(`Rate limit check for ${key} on ${name}: ${state.count}/${limit} (remaining: ${remaining}, resets in ${reset}s)`)
|
||||||
|
|
||||||
|
if (state.count > limit) {
|
||||||
|
res.setHeader('Retry-After', String(reset))
|
||||||
|
throw new APIError(429, 'Too Many Requests: rate limit exceeded.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const typedEndpoint = <N extends APIPath>(
|
export const typedEndpoint = <N extends APIPath>(
|
||||||
name: N,
|
name: N,
|
||||||
handler: APIHandler<N>
|
handler: APIHandler<N>
|
||||||
) => {
|
) => {
|
||||||
const { props: propSchema, authed: authRequired, method } = API[name]
|
const {props: propSchema, authed: authRequired, rateLimited = false, method} = API[name] as APISchema<N>
|
||||||
|
|
||||||
return async (req: Request, res: Response, next: NextFunction) => {
|
return async (req: Request, res: Response, next: NextFunction) => {
|
||||||
let authUser: AuthedUser | undefined = undefined
|
let authUser: AuthedUser | undefined = undefined
|
||||||
@@ -171,6 +242,15 @@ export const typedEndpoint = <N extends APIPath>(
|
|||||||
if (authRequired) return next(e)
|
if (authRequired) return next(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply rate limiting before invoking the handler
|
||||||
|
if (rateLimited) {
|
||||||
|
try {
|
||||||
|
checkRateLimit(String(name), req, res, authUser)
|
||||||
|
} catch (e) {
|
||||||
|
return next(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const props = {
|
const props = {
|
||||||
...(method === 'GET' ? req.query : req.body),
|
...(method === 'GET' ? req.query : req.body),
|
||||||
...req.params,
|
...req.params,
|
||||||
@@ -194,8 +274,8 @@ export const typedEndpoint = <N extends APIPath>(
|
|||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
// Convert bigint to number, b/c JSON doesn't support bigint.
|
// Convert bigint to number, b/c JSON doesn't support bigint.
|
||||||
const convertedResult = deepConvertBigIntToNumber(result)
|
const convertedResult = deepConvertBigIntToNumber(result)
|
||||||
|
// console.debug('API result', convertedResult)
|
||||||
res.status(200).json(convertedResult ?? { success: true })
|
res.status(200).json(convertedResult ?? {success: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasContinue) {
|
if (hasContinue) {
|
||||||
|
|||||||
387
backend/api/src/helpers/private-messages.ts
Normal file
387
backend/api/src/helpers/private-messages.ts
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
import {Json} from 'common/supabase/schema'
|
||||||
|
import {SupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
import {ChatVisibility} from 'common/chat-message'
|
||||||
|
import {User} from 'common/user'
|
||||||
|
import {first} from 'lodash'
|
||||||
|
import {log} from 'shared/monitoring/log'
|
||||||
|
import {getPrivateUser, getUser} from 'shared/utils'
|
||||||
|
import {type JSONContent} from '@tiptap/core'
|
||||||
|
import {APIError} from 'common/api/utils'
|
||||||
|
import {broadcast} from 'shared/websockets/server'
|
||||||
|
import {track} from 'shared/analytics'
|
||||||
|
import {sendNewMessageEmail} from 'email/functions/helpers'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
|
import utc from 'dayjs/plugin/utc'
|
||||||
|
import timezone from 'dayjs/plugin/timezone'
|
||||||
|
import webPush from 'web-push'
|
||||||
|
import {parseJsonContentToText} from "common/util/parse"
|
||||||
|
import {encryptMessage} from "shared/encryption"
|
||||||
|
import * as admin from 'firebase-admin'
|
||||||
|
import {TokenMessage} from "firebase-admin/lib/messaging/messaging-api";
|
||||||
|
|
||||||
|
dayjs.extend(utc)
|
||||||
|
dayjs.extend(timezone)
|
||||||
|
|
||||||
|
export const leaveChatContent = (userName: string) => ({
|
||||||
|
type: 'doc',
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: 'paragraph',
|
||||||
|
content: [{text: `${userName} left the chat`, type: 'text'}],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
// export const joinChatContent = (userName: string) => {
|
||||||
|
// return {
|
||||||
|
// type: 'doc',
|
||||||
|
// content: [
|
||||||
|
// {
|
||||||
|
// type: 'paragraph',
|
||||||
|
// content: [{text: `${userName} joined the chat!`, type: 'text'}],
|
||||||
|
// },
|
||||||
|
// ],
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
export const insertPrivateMessage = async (
|
||||||
|
content: Json,
|
||||||
|
channelId: number,
|
||||||
|
userId: string,
|
||||||
|
visibility: ChatVisibility,
|
||||||
|
pg: SupabaseDirectClient
|
||||||
|
) => {
|
||||||
|
const plaintext = JSON.stringify(content)
|
||||||
|
const {ciphertext, iv, tag} = encryptMessage(plaintext)
|
||||||
|
const lastMessage = await pg.one(
|
||||||
|
`insert into private_user_messages (ciphertext, iv, tag, channel_id, user_id, visibility)
|
||||||
|
values ($1, $2, $3, $4, $5, $6)
|
||||||
|
returning created_time`,
|
||||||
|
[ciphertext, iv, tag, channelId, userId, visibility]
|
||||||
|
)
|
||||||
|
await pg.none(
|
||||||
|
`update private_user_message_channels
|
||||||
|
set last_updated_time = $1
|
||||||
|
where id = $2`,
|
||||||
|
[lastMessage.created_time, channelId]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const addUsersToPrivateMessageChannel = async (
|
||||||
|
userIds: string[],
|
||||||
|
channelId: number,
|
||||||
|
pg: SupabaseDirectClient
|
||||||
|
) => {
|
||||||
|
await Promise.all(
|
||||||
|
userIds.map((id) =>
|
||||||
|
pg.none(
|
||||||
|
`insert into private_user_message_channel_members (channel_id, user_id, role, status)
|
||||||
|
values ($1, $2, 'member', 'proposed')
|
||||||
|
on conflict do nothing
|
||||||
|
`,
|
||||||
|
[channelId, id]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await pg.none(
|
||||||
|
`update private_user_message_channels
|
||||||
|
set last_updated_time = now()
|
||||||
|
where id = $1`,
|
||||||
|
[channelId]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function broadcastPrivateMessages(
|
||||||
|
pg: SupabaseDirectClient,
|
||||||
|
channelId: number,
|
||||||
|
userId: string,
|
||||||
|
) {
|
||||||
|
const otherUserIds = await pg.map<string>(
|
||||||
|
`select user_id
|
||||||
|
from private_user_message_channel_members
|
||||||
|
where channel_id = $1
|
||||||
|
and user_id != $2
|
||||||
|
and status != 'left'
|
||||||
|
`,
|
||||||
|
[channelId, userId],
|
||||||
|
(r) => r.user_id
|
||||||
|
)
|
||||||
|
otherUserIds.concat(userId).forEach((otherUserId) => {
|
||||||
|
broadcast(`private-user-messages/${otherUserId}`, {})
|
||||||
|
})
|
||||||
|
return otherUserIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createPrivateUserMessageMain = async (
|
||||||
|
creator: User,
|
||||||
|
channelId: number,
|
||||||
|
content: JSONContent,
|
||||||
|
pg: SupabaseDirectClient,
|
||||||
|
visibility: ChatVisibility
|
||||||
|
) => {
|
||||||
|
log('createPrivateUserMessageMain', creator, channelId, content)
|
||||||
|
|
||||||
|
// Normally, users can only submit messages to channels that they are members of
|
||||||
|
const authorized = await pg.oneOrNone(
|
||||||
|
`select 1
|
||||||
|
from private_user_message_channel_members
|
||||||
|
where channel_id = $1
|
||||||
|
and user_id = $2`,
|
||||||
|
[channelId, creator.id]
|
||||||
|
)
|
||||||
|
if (!authorized)
|
||||||
|
throw new APIError(403, 'You are not authorized to post to this channel')
|
||||||
|
|
||||||
|
await insertPrivateMessage(content, channelId, creator.id, visibility, pg)
|
||||||
|
|
||||||
|
const privateMessage = {
|
||||||
|
content: content as Json,
|
||||||
|
channel_id: channelId,
|
||||||
|
user_id: creator.id,
|
||||||
|
}
|
||||||
|
const otherUserIds = await broadcastPrivateMessages(pg, channelId, creator.id);
|
||||||
|
|
||||||
|
// Fire and forget safely
|
||||||
|
void notifyOtherUserInChannelIfInactive(channelId, creator, content, pg)
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('notifyOtherUserInChannelIfInactive failed', err)
|
||||||
|
})
|
||||||
|
|
||||||
|
track(creator.id, 'send private message', {
|
||||||
|
channelId,
|
||||||
|
otherUserIds,
|
||||||
|
})
|
||||||
|
|
||||||
|
return privateMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
const notifyOtherUserInChannelIfInactive = async (
|
||||||
|
channelId: number,
|
||||||
|
creator: User,
|
||||||
|
content: JSONContent,
|
||||||
|
pg: SupabaseDirectClient
|
||||||
|
) => {
|
||||||
|
const otherUserIds = await pg.manyOrNone<{ user_id: string }>(
|
||||||
|
`select user_id
|
||||||
|
from private_user_message_channel_members
|
||||||
|
where channel_id = $1
|
||||||
|
and user_id != $2
|
||||||
|
and status != 'left'
|
||||||
|
`,
|
||||||
|
[channelId, creator.id]
|
||||||
|
)
|
||||||
|
// We're only sending notifs for 1:1 channels
|
||||||
|
if (!otherUserIds || otherUserIds.length > 1) return
|
||||||
|
|
||||||
|
const receiverId = first(otherUserIds)?.user_id
|
||||||
|
if (!receiverId) return
|
||||||
|
|
||||||
|
// TODO: notification only for active user
|
||||||
|
|
||||||
|
const receiver = await getUser(receiverId)
|
||||||
|
console.debug('receiver:', receiver)
|
||||||
|
if (!receiver) return
|
||||||
|
|
||||||
|
// Push notifs
|
||||||
|
const textContent = parseJsonContentToText(content)
|
||||||
|
const payload = {
|
||||||
|
title: `${creator.name}`,
|
||||||
|
body: textContent,
|
||||||
|
url: `/messages/${channelId}`,
|
||||||
|
}
|
||||||
|
await sendWebNotifications(pg, receiverId, JSON.stringify(payload))
|
||||||
|
await sendMobileNotifications(pg, receiverId, payload)
|
||||||
|
|
||||||
|
const startOfDay = dayjs()
|
||||||
|
.tz('America/Los_Angeles')
|
||||||
|
.startOf('day')
|
||||||
|
.toISOString()
|
||||||
|
const previousMessagesThisDayBetweenTheseUsers = await pg.one(
|
||||||
|
`select count(*)
|
||||||
|
from private_user_messages
|
||||||
|
where channel_id = $1
|
||||||
|
and user_id = $2
|
||||||
|
and created_time > $3
|
||||||
|
`,
|
||||||
|
[channelId, creator.id, startOfDay]
|
||||||
|
)
|
||||||
|
log('previous messages this day', previousMessagesThisDayBetweenTheseUsers)
|
||||||
|
if (previousMessagesThisDayBetweenTheseUsers.count > 1) return
|
||||||
|
|
||||||
|
await createNewMessageNotification(creator, receiver, channelId)
|
||||||
|
}
|
||||||
|
|
||||||
|
const createNewMessageNotification = async (
|
||||||
|
fromUser: User,
|
||||||
|
toUser: User,
|
||||||
|
channelId: number,
|
||||||
|
) => {
|
||||||
|
const privateUser = await getPrivateUser(toUser.id)
|
||||||
|
console.debug('privateUser:', privateUser)
|
||||||
|
if (!privateUser) return
|
||||||
|
await sendNewMessageEmail(privateUser, fromUser, toUser, channelId)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function sendWebNotifications(
|
||||||
|
pg: SupabaseDirectClient,
|
||||||
|
userId: string,
|
||||||
|
payload: string,
|
||||||
|
) {
|
||||||
|
webPush.setVapidDetails(
|
||||||
|
'mailto:hello@compassmeet.com',
|
||||||
|
process.env.VAPID_PUBLIC_KEY!,
|
||||||
|
process.env.VAPID_PRIVATE_KEY!
|
||||||
|
)
|
||||||
|
// Retrieve subscription from the database
|
||||||
|
const subscriptions = await getSubscriptionsFromDB(pg, userId)
|
||||||
|
for (const subscription of subscriptions) {
|
||||||
|
try {
|
||||||
|
console.log('Sending notification to:', subscription.endpoint, payload)
|
||||||
|
await webPush.sendNotification(subscription, payload)
|
||||||
|
} catch (err: any) {
|
||||||
|
console.log('Failed to send notification', err)
|
||||||
|
if (err.statusCode === 410 || err.statusCode === 404) {
|
||||||
|
console.warn('Removing expired subscription', subscription.endpoint)
|
||||||
|
await removeSubscription(pg, subscription.endpoint, userId)
|
||||||
|
} else {
|
||||||
|
console.error('Push failed', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function getSubscriptionsFromDB(
|
||||||
|
pg: SupabaseDirectClient,
|
||||||
|
userId: string,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const subscriptions = await pg.manyOrNone(`
|
||||||
|
select endpoint, keys
|
||||||
|
from push_subscriptions
|
||||||
|
where user_id = $1
|
||||||
|
`, [userId]
|
||||||
|
)
|
||||||
|
|
||||||
|
return subscriptions.map(sub => ({
|
||||||
|
endpoint: sub.endpoint,
|
||||||
|
keys: sub.keys,
|
||||||
|
}))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching subscriptions', err)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeSubscription(
|
||||||
|
pg: SupabaseDirectClient,
|
||||||
|
endpoint: any,
|
||||||
|
userId: string,
|
||||||
|
) {
|
||||||
|
await pg.none(
|
||||||
|
`DELETE
|
||||||
|
FROM push_subscriptions
|
||||||
|
WHERE endpoint = $1
|
||||||
|
AND user_id = $2`,
|
||||||
|
[endpoint, userId]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeMobileSubscription(
|
||||||
|
pg: SupabaseDirectClient,
|
||||||
|
token: any,
|
||||||
|
userId: string,
|
||||||
|
) {
|
||||||
|
await pg.none(
|
||||||
|
`DELETE
|
||||||
|
FROM push_subscriptions_mobile
|
||||||
|
WHERE token = $1
|
||||||
|
AND user_id = $2`,
|
||||||
|
[token, userId]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async function sendMobileNotifications(
|
||||||
|
pg: SupabaseDirectClient,
|
||||||
|
userId: string,
|
||||||
|
payload: PushPayload,
|
||||||
|
) {
|
||||||
|
const subscriptions = await getMobileSubscriptionsFromDB(pg, userId)
|
||||||
|
for (const subscription of subscriptions) {
|
||||||
|
await sendPushToToken(pg, userId, subscription.token, payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PushPayload {
|
||||||
|
title: string
|
||||||
|
body: string
|
||||||
|
url: string
|
||||||
|
data?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendPushToToken(
|
||||||
|
pg: SupabaseDirectClient,
|
||||||
|
userId: string,
|
||||||
|
token: string,
|
||||||
|
payload: PushPayload,
|
||||||
|
) {
|
||||||
|
const message: TokenMessage = {
|
||||||
|
token,
|
||||||
|
android: {
|
||||||
|
notification: {
|
||||||
|
title: payload.title,
|
||||||
|
body: payload.body,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
endpoint: payload.url,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fine to create at each call, as it's a cached singleton
|
||||||
|
const fcm = admin.messaging()
|
||||||
|
console.log('Sending notification to:', token, message)
|
||||||
|
const response = await fcm.send(message)
|
||||||
|
console.log('Push sent successfully:', response)
|
||||||
|
return response
|
||||||
|
} catch (err: unknown) {
|
||||||
|
// Check if it's a Firebase Messaging error
|
||||||
|
if (err instanceof Error && 'code' in err) {
|
||||||
|
const firebaseError = err as { code: string; message: string }
|
||||||
|
console.warn('Firebase error:', firebaseError.code, firebaseError.message)
|
||||||
|
|
||||||
|
// Handle specific error cases here if needed
|
||||||
|
// For example, if token is no longer valid:
|
||||||
|
if (firebaseError.code === 'messaging/registration-token-not-registered' ||
|
||||||
|
firebaseError.code === 'messaging/invalid-argument') {
|
||||||
|
console.warn('Removing invalid FCM token')
|
||||||
|
await removeMobileSubscription(pg, token, userId)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.error('Unknown error:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function getMobileSubscriptionsFromDB(
|
||||||
|
pg: SupabaseDirectClient,
|
||||||
|
userId: string,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const subscriptions = await pg.manyOrNone(`
|
||||||
|
select token
|
||||||
|
from push_subscriptions_mobile
|
||||||
|
where user_id = $1
|
||||||
|
`, [userId]
|
||||||
|
)
|
||||||
|
|
||||||
|
return subscriptions
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error fetching subscriptions', err)
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
import { Json } from 'common/supabase/schema'
|
|
||||||
import { SupabaseDirectClient } from 'shared/supabase/init'
|
|
||||||
import { ChatVisibility } from 'common/chat-message'
|
|
||||||
import { User } from 'common/user'
|
|
||||||
import { first } from 'lodash'
|
|
||||||
import { log } from 'shared/monitoring/log'
|
|
||||||
import { getPrivateUser, getUser } from 'shared/utils'
|
|
||||||
import { type JSONContent } from '@tiptap/core'
|
|
||||||
import { APIError } from 'common/api/utils'
|
|
||||||
import { broadcast } from 'shared/websockets/server'
|
|
||||||
import { track } from 'shared/analytics'
|
|
||||||
import { sendNewMessageEmail } from 'email/functions/helpers'
|
|
||||||
import dayjs from 'dayjs'
|
|
||||||
import utc from 'dayjs/plugin/utc'
|
|
||||||
import timezone from 'dayjs/plugin/timezone'
|
|
||||||
|
|
||||||
dayjs.extend(utc)
|
|
||||||
dayjs.extend(timezone)
|
|
||||||
|
|
||||||
export const leaveChatContent = (userName: string) => ({
|
|
||||||
type: 'doc',
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'paragraph',
|
|
||||||
content: [{ text: `${userName} left the chat`, type: 'text' }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
})
|
|
||||||
export const joinChatContent = (userName: string) => {
|
|
||||||
return {
|
|
||||||
type: 'doc',
|
|
||||||
content: [
|
|
||||||
{
|
|
||||||
type: 'paragraph',
|
|
||||||
content: [{ text: `${userName} joined the chat!`, type: 'text' }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const insertPrivateMessage = async (
|
|
||||||
content: Json,
|
|
||||||
channelId: number,
|
|
||||||
userId: string,
|
|
||||||
visibility: ChatVisibility,
|
|
||||||
pg: SupabaseDirectClient
|
|
||||||
) => {
|
|
||||||
const lastMessage = await pg.one(
|
|
||||||
`insert into private_user_messages (content, channel_id, user_id, visibility)
|
|
||||||
values ($1, $2, $3, $4) returning created_time`,
|
|
||||||
[content, channelId, userId, visibility]
|
|
||||||
)
|
|
||||||
await pg.none(
|
|
||||||
`update private_user_message_channels set last_updated_time = $1 where id = $2`,
|
|
||||||
[lastMessage.created_time, channelId]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const addUsersToPrivateMessageChannel = async (
|
|
||||||
userIds: string[],
|
|
||||||
channelId: number,
|
|
||||||
pg: SupabaseDirectClient
|
|
||||||
) => {
|
|
||||||
await Promise.all(
|
|
||||||
userIds.map((id) =>
|
|
||||||
pg.none(
|
|
||||||
`insert into private_user_message_channel_members (channel_id, user_id, role, status)
|
|
||||||
values
|
|
||||||
($1, $2, 'member', 'proposed')
|
|
||||||
on conflict do nothing
|
|
||||||
`,
|
|
||||||
[channelId, id]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await pg.none(
|
|
||||||
`update private_user_message_channels set last_updated_time = now() where id = $1`,
|
|
||||||
[channelId]
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export const createPrivateUserMessageMain = async (
|
|
||||||
creator: User,
|
|
||||||
channelId: number,
|
|
||||||
content: JSONContent,
|
|
||||||
pg: SupabaseDirectClient,
|
|
||||||
visibility: ChatVisibility
|
|
||||||
) => {
|
|
||||||
log('createPrivateUserMessageMain', creator, channelId, content)
|
|
||||||
// Normally, users can only submit messages to channels that they are members of
|
|
||||||
const authorized = await pg.oneOrNone(
|
|
||||||
`select 1
|
|
||||||
from private_user_message_channel_members
|
|
||||||
where channel_id = $1
|
|
||||||
and user_id = $2`,
|
|
||||||
[channelId, creator.id]
|
|
||||||
)
|
|
||||||
if (!authorized)
|
|
||||||
throw new APIError(403, 'You are not authorized to post to this channel')
|
|
||||||
|
|
||||||
await notifyOtherUserInChannelIfInactive(channelId, creator, pg)
|
|
||||||
await insertPrivateMessage(content, channelId, creator.id, visibility, pg)
|
|
||||||
|
|
||||||
const privateMessage = {
|
|
||||||
content: content as Json,
|
|
||||||
channel_id: channelId,
|
|
||||||
user_id: creator.id,
|
|
||||||
}
|
|
||||||
|
|
||||||
const otherUserIds = await pg.map<string>(
|
|
||||||
`select user_id from private_user_message_channel_members
|
|
||||||
where channel_id = $1 and user_id != $2
|
|
||||||
and status != 'left'
|
|
||||||
`,
|
|
||||||
[channelId, creator.id],
|
|
||||||
(r) => r.user_id
|
|
||||||
)
|
|
||||||
otherUserIds.concat(creator.id).forEach((otherUserId) => {
|
|
||||||
broadcast(`private-user-messages/${otherUserId}`, {})
|
|
||||||
})
|
|
||||||
|
|
||||||
track(creator.id, 'send private message', {
|
|
||||||
channelId,
|
|
||||||
otherUserIds,
|
|
||||||
})
|
|
||||||
|
|
||||||
return privateMessage
|
|
||||||
}
|
|
||||||
|
|
||||||
const notifyOtherUserInChannelIfInactive = async (
|
|
||||||
channelId: number,
|
|
||||||
creator: User,
|
|
||||||
pg: SupabaseDirectClient
|
|
||||||
) => {
|
|
||||||
const otherUserIds = await pg.manyOrNone<{ user_id: string }>(
|
|
||||||
`select user_id from private_user_message_channel_members
|
|
||||||
where channel_id = $1 and user_id != $2
|
|
||||||
and status != 'left'
|
|
||||||
`,
|
|
||||||
[channelId, creator.id]
|
|
||||||
)
|
|
||||||
// We're only sending notifs for 1:1 channels
|
|
||||||
if (!otherUserIds || otherUserIds.length > 1) return
|
|
||||||
|
|
||||||
const otherUserId = first(otherUserIds)
|
|
||||||
if (!otherUserId) return
|
|
||||||
|
|
||||||
const startOfDay = dayjs()
|
|
||||||
.tz('America/Los_Angeles')
|
|
||||||
.startOf('day')
|
|
||||||
.toISOString()
|
|
||||||
const previousMessagesThisDayBetweenTheseUsers = await pg.one(
|
|
||||||
`select count(*) from private_user_messages
|
|
||||||
where channel_id = $1
|
|
||||||
and user_id = $2
|
|
||||||
and created_time > $3
|
|
||||||
`,
|
|
||||||
[channelId, creator.id, startOfDay]
|
|
||||||
)
|
|
||||||
log('previous messages this day', previousMessagesThisDayBetweenTheseUsers)
|
|
||||||
if (previousMessagesThisDayBetweenTheseUsers.count > 0) return
|
|
||||||
|
|
||||||
// TODO: notification only for active user
|
|
||||||
|
|
||||||
const otherUser = await getUser(otherUserId.user_id)
|
|
||||||
console.log('otherUser:', otherUser)
|
|
||||||
if (!otherUser) return
|
|
||||||
|
|
||||||
await createNewMessageNotification(creator, otherUser, channelId)
|
|
||||||
}
|
|
||||||
|
|
||||||
const createNewMessageNotification = async (
|
|
||||||
fromUser: User,
|
|
||||||
toUser: User,
|
|
||||||
channelId: number
|
|
||||||
) => {
|
|
||||||
const privateUser = await getPrivateUser(toUser.id)
|
|
||||||
console.log('privateUser:', privateUser)
|
|
||||||
if (!privateUser) return
|
|
||||||
await sendNewMessageEmail(privateUser, fromUser, toUser, channelId)
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ import { createSupabaseDirectClient } from 'shared/supabase/init'
|
|||||||
import {
|
import {
|
||||||
insertPrivateMessage,
|
insertPrivateMessage,
|
||||||
leaveChatContent,
|
leaveChatContent,
|
||||||
} from 'api/junk-drawer/private-messages'
|
} from 'api/helpers/private-messages'
|
||||||
|
|
||||||
export const leavePrivateUserMessageChannel: APIHandler<
|
export const leavePrivateUserMessageChannel: APIHandler<
|
||||||
'leave-private-user-message-channel'
|
'leave-private-user-message-channel'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||||
import { APIError, APIHandler } from './helpers/endpoint'
|
import { APIError, APIHandler } from './helpers/endpoint'
|
||||||
import { createLoveLikeNotification } from 'shared/create-love-notification'
|
import { createProfileLikeNotification } from 'shared/create-profile-notification'
|
||||||
import { getHasFreeLike } from './has-free-like'
|
import { getHasFreeLike } from './has-free-like'
|
||||||
import { log } from 'shared/utils'
|
import { log } from 'shared/utils'
|
||||||
import { tryCatch } from 'common/util/try-catch'
|
import { tryCatch } from 'common/util/try-catch'
|
||||||
@@ -15,7 +15,7 @@ export const likeProfile: APIHandler<'like-profile'> = async (props, auth) => {
|
|||||||
if (remove) {
|
if (remove) {
|
||||||
const { error } = await tryCatch(
|
const { error } = await tryCatch(
|
||||||
pg.none(
|
pg.none(
|
||||||
'delete from love_likes where creator_id = $1 and target_id = $2',
|
'delete from profile_likes where creator_id = $1 and target_id = $2',
|
||||||
[creatorId, targetUserId]
|
[creatorId, targetUserId]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -28,8 +28,8 @@ export const likeProfile: APIHandler<'like-profile'> = async (props, auth) => {
|
|||||||
|
|
||||||
// Check if like already exists
|
// Check if like already exists
|
||||||
const { data: existing } = await tryCatch(
|
const { data: existing } = await tryCatch(
|
||||||
pg.oneOrNone<Row<'love_likes'>>(
|
pg.oneOrNone<Row<'profile_likes'>>(
|
||||||
'select * from love_likes where creator_id = $1 and target_id = $2',
|
'select * from profile_likes where creator_id = $1 and target_id = $2',
|
||||||
[creatorId, targetUserId]
|
[creatorId, targetUserId]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -48,8 +48,8 @@ export const likeProfile: APIHandler<'like-profile'> = async (props, auth) => {
|
|||||||
|
|
||||||
// Insert the new like
|
// Insert the new like
|
||||||
const { data, error } = await tryCatch(
|
const { data, error } = await tryCatch(
|
||||||
pg.one<Row<'love_likes'>>(
|
pg.one<Row<'profile_likes'>>(
|
||||||
'insert into love_likes (creator_id, target_id) values ($1, $2) returning *',
|
'insert into profile_likes (creator_id, target_id) values ($1, $2) returning *',
|
||||||
[creatorId, targetUserId]
|
[creatorId, targetUserId]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -59,7 +59,7 @@ export const likeProfile: APIHandler<'like-profile'> = async (props, auth) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const continuation = async () => {
|
const continuation = async () => {
|
||||||
await createLoveLikeNotification(data)
|
await createProfileLikeNotification(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
64
backend/api/src/public/swagger.css
Normal file
64
backend/api/src/public/swagger.css
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
body {
|
||||||
|
background-color: #1e1e1e !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
.swagger-ui p,
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6,
|
||||||
|
label,
|
||||||
|
.btn,
|
||||||
|
.parameter__name,
|
||||||
|
.parameter__type,
|
||||||
|
.parameter__in,
|
||||||
|
.response-control-media-type__title,
|
||||||
|
table thead tr td,
|
||||||
|
table thead tr th,
|
||||||
|
.tab li,
|
||||||
|
.response-col_links,
|
||||||
|
.opblock-summary-description {
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
.swagger-ui .topbar, .opblock-body select, textarea {
|
||||||
|
background-color: #2b2b2b !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
.swagger-ui .opblock {
|
||||||
|
background-color: #2c2c2c !important;
|
||||||
|
border-color: #fff !important;
|
||||||
|
}
|
||||||
|
.swagger-ui .opblock .opblock-summary-method {
|
||||||
|
background-color: #1f1f1f !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
.swagger-ui .opblock .opblock-section-header {
|
||||||
|
background: #1f1f1f !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
.swagger-ui .responses-wrapper {
|
||||||
|
background-color: #1f1f1f !important;
|
||||||
|
}
|
||||||
|
.swagger-ui .response-col_status {
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
.swagger-ui .scheme-container {
|
||||||
|
background-color: #1f1f1f !important;
|
||||||
|
}
|
||||||
|
.swagger-ui .modal-ux, input {
|
||||||
|
background-color: #1f1f1f !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
.swagger-ui svg path {
|
||||||
|
fill: white !important;
|
||||||
|
}
|
||||||
|
.swagger-ui .close-modal svg {
|
||||||
|
color: #1e90ff !important;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: #1e90ff !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
60
backend/api/src/react-to-message.ts
Normal file
60
backend/api/src/react-to-message.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import {APIError, APIHandler} from './helpers/endpoint'
|
||||||
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
import {broadcastPrivateMessages} from "api/helpers/private-messages";
|
||||||
|
|
||||||
|
|
||||||
|
export const reactToMessage: APIHandler<'react-to-message'> = async ({messageId, reaction, toDelete}, auth) => {
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
|
|
||||||
|
// Verify user is a member of the channel
|
||||||
|
const message = await pg.oneOrNone(
|
||||||
|
`SELECT *
|
||||||
|
FROM private_user_message_channel_members m
|
||||||
|
JOIN private_user_messages msg ON msg.channel_id = m.channel_id
|
||||||
|
WHERE m.user_id = $1
|
||||||
|
AND msg.id = $2`,
|
||||||
|
[auth.uid, messageId]
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!message) {
|
||||||
|
throw new APIError(403, 'Not authorized to react to this message')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toDelete) {
|
||||||
|
// Remove the reaction
|
||||||
|
await pg.none(
|
||||||
|
`UPDATE private_user_messages
|
||||||
|
SET reactions = reactions - $1
|
||||||
|
WHERE id = $2
|
||||||
|
AND reactions -> $1 ? $3`,
|
||||||
|
[reaction, messageId, auth.uid]
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Toggle reaction
|
||||||
|
await pg.none(
|
||||||
|
`UPDATE private_user_messages
|
||||||
|
SET reactions =
|
||||||
|
CASE
|
||||||
|
WHEN reactions -> $1 IS NOT NULL
|
||||||
|
THEN reactions - $1
|
||||||
|
ELSE jsonb_set(
|
||||||
|
COALESCE(reactions, '{}'::jsonb),
|
||||||
|
ARRAY [$1],
|
||||||
|
(
|
||||||
|
COALESCE(reactions -> $1, '[]'::jsonb) || to_jsonb($2::text)
|
||||||
|
),
|
||||||
|
TRUE
|
||||||
|
)
|
||||||
|
END
|
||||||
|
WHERE id = $3`,
|
||||||
|
[reaction, auth.uid, messageId]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
void broadcastPrivateMessages(pg, message.channel_id, auth.uid)
|
||||||
|
.catch((err) => {
|
||||||
|
console.error('broadcastPrivateMessages failed', err)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {success: true}
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { APIError, APIHandler } from './helpers/endpoint'
|
import {APIError, APIHandler} from './helpers/endpoint'
|
||||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
import { tryCatch } from 'common/util/try-catch'
|
import {tryCatch} from 'common/util/try-catch'
|
||||||
import { insert } from 'shared/supabase/utils'
|
import {insert} from 'shared/supabase/utils'
|
||||||
|
import {sendDiscordMessage} from "common/discord/core";
|
||||||
|
import {Row} from "common/supabase/utils";
|
||||||
|
import {DOMAIN} from "common/envs/constants";
|
||||||
|
|
||||||
// abusable: people can report the wrong person, that didn't write the comment
|
// abusable: people can report the wrong person, that didn't write the comment
|
||||||
// but in practice we check it manually and nothing bad happens to them automatically
|
// but in practice we check it manually and nothing bad happens to them automatically
|
||||||
@@ -33,5 +36,38 @@ export const report: APIHandler<'report'> = async (body, auth) => {
|
|||||||
throw new APIError(500, 'Failed to create report: ' + result.error.message)
|
throw new APIError(500, 'Failed to create report: ' + result.error.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true }
|
const continuation = async () => {
|
||||||
|
try {
|
||||||
|
const {data: reporter, error} = await tryCatch(
|
||||||
|
pg.oneOrNone<Row<'users'>>('select * from users where id = $1', [auth.uid])
|
||||||
|
)
|
||||||
|
if (error) {
|
||||||
|
console.error('Failed to get user for report', error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const {data: reported, error: userError} = await tryCatch(
|
||||||
|
pg.oneOrNone<Row<'users'>>('select * from users where id = $1', [contentOwnerId])
|
||||||
|
)
|
||||||
|
if (userError) {
|
||||||
|
console.error('Failed to get reported user for report', userError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const message: string = `
|
||||||
|
🚨 **New Report** 🚨
|
||||||
|
**Type:** ${contentType}
|
||||||
|
**Content ID:** ${contentId}
|
||||||
|
**Reporter:** ${reporter?.name} ([@${reporter?.username}](https://www.${DOMAIN}/${reporter?.username}))
|
||||||
|
**Reported:** ${reported?.name} ([@${reported?.username}](https://www.${DOMAIN}/${reported?.username}))
|
||||||
|
`
|
||||||
|
await sendDiscordMessage(message, 'reports')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to send discord reports', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: {},
|
||||||
|
continue: continuation,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
28
backend/api/src/save-subscription-mobile.ts
Normal file
28
backend/api/src/save-subscription-mobile.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import {APIError, APIHandler} from './helpers/endpoint'
|
||||||
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
|
||||||
|
export const saveSubscriptionMobile: APIHandler<'save-subscription-mobile'> = async (body, auth) => {
|
||||||
|
const {token} = body
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
throw new APIError(400, `Invalid subscription object`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = auth?.uid
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
|
await pg.none(`
|
||||||
|
insert into push_subscriptions_mobile(token, platform, user_id)
|
||||||
|
values ($1, $2, $3)
|
||||||
|
on conflict(token) do update set platform = excluded.platform,
|
||||||
|
user_id = excluded.user_id
|
||||||
|
`,
|
||||||
|
[token, 'android', userId]
|
||||||
|
);
|
||||||
|
return {success: true};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving subscription', err);
|
||||||
|
throw new APIError(500, `Failed to save subscription`)
|
||||||
|
}
|
||||||
|
}
|
||||||
41
backend/api/src/save-subscription.ts
Normal file
41
backend/api/src/save-subscription.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import {APIError, APIHandler} from './helpers/endpoint'
|
||||||
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
|
||||||
|
export const saveSubscription: APIHandler<'save-subscription'> = async (body, auth) => {
|
||||||
|
const {subscription} = body
|
||||||
|
|
||||||
|
if (!subscription?.endpoint || !subscription?.keys) {
|
||||||
|
throw new APIError(400, `Invalid subscription object`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = auth?.uid
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
|
// Check if a subscription already exists
|
||||||
|
const exists = await pg.oneOrNone(
|
||||||
|
'select id from push_subscriptions where endpoint = $1',
|
||||||
|
[subscription.endpoint]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
// Already exists, optionally update keys and userId
|
||||||
|
await pg.none(
|
||||||
|
'update push_subscriptions set keys = $1, user_id = $2 where id = $3',
|
||||||
|
[subscription.keys, userId, exists.id]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await pg.none(
|
||||||
|
`insert into push_subscriptions(endpoint, keys, user_id) values($1, $2, $3)
|
||||||
|
on conflict(endpoint) do update set keys = excluded.keys
|
||||||
|
`,
|
||||||
|
[subscription.endpoint, subscription.keys, userId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {success: true};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving subscription', err);
|
||||||
|
throw new APIError(500, `Failed to save subscription`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,5 +4,6 @@ import {geodbFetch} from "common/geodb";
|
|||||||
export const searchLocation: APIHandler<'search-location'> = async (body) => {
|
export const searchLocation: APIHandler<'search-location'> = async (body) => {
|
||||||
const {term, limit} = body
|
const {term, limit} = body
|
||||||
const endpoint = `/cities?namePrefix=${term}&limit=${limit ?? 10}&offset=0&sort=-population`
|
const endpoint = `/cities?namePrefix=${term}&limit=${limit ?? 10}&offset=0&sort=-population`
|
||||||
|
// const endpoint = `/countries?namePrefix=${term}&limit=${limit ?? 10}&offset=0`
|
||||||
return await geodbFetch(endpoint)
|
return await geodbFetch(endpoint)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,12 @@ import {APIHandler} from './helpers/endpoint'
|
|||||||
import {geodbFetch} from "common/geodb";
|
import {geodbFetch} from "common/geodb";
|
||||||
|
|
||||||
const searchNearCityMain = async (cityId: string, radius: number) => {
|
const searchNearCityMain = async (cityId: string, radius: number) => {
|
||||||
// Limit to 10 cities for now for free plan, was 100 before (may need to buy plan)
|
const endpoint = `/cities/${cityId}/nearbyCities?radius=${radius}&offset=0&sort=-population&limit=100`
|
||||||
const endpoint = `/cities/${cityId}/nearbyCities?radius=${radius}&offset=0&sort=-population&limit=10`
|
|
||||||
return await geodbFetch(endpoint)
|
return await geodbFetch(endpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const searchNearCity: APIHandler<'search-near-city'> = async (body) => {
|
export const searchNearCity: APIHandler<'search-near-city'> = async (body) => {
|
||||||
const { cityId, radius } = body
|
const {cityId, radius} = body
|
||||||
return await searchNearCityMain(cityId, radius)
|
return await searchNearCityMain(cityId, radius)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +1,26 @@
|
|||||||
import { constructPrefixTsQuery } from 'shared/helpers/search'
|
import {constructPrefixTsQuery} from 'shared/helpers/search'
|
||||||
import {
|
import {from, limit, orderBy, renderSql, select, where,} from 'shared/supabase/sql-builder'
|
||||||
from,
|
import {type APIHandler} from './helpers/endpoint'
|
||||||
join,
|
import {convertUser} from 'common/supabase/users'
|
||||||
limit,
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
orderBy,
|
import {toUserAPIResponse} from 'common/api/user-types'
|
||||||
renderSql,
|
import {uniqBy} from 'lodash'
|
||||||
select,
|
|
||||||
where,
|
|
||||||
} from 'shared/supabase/sql-builder'
|
|
||||||
import { type APIHandler } from './helpers/endpoint'
|
|
||||||
import { convertUser } from 'common/supabase/users'
|
|
||||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
|
||||||
import { toUserAPIResponse } from 'common/api/user-types'
|
|
||||||
import { uniqBy } from 'lodash'
|
|
||||||
|
|
||||||
export const searchUsers: APIHandler<'search-users'> = async (props, auth) => {
|
export const searchUsers: APIHandler<'search-users'> = async (props, _auth) => {
|
||||||
const { term, page, limit } = props
|
const {term, page, limit} = props
|
||||||
|
|
||||||
const pg = createSupabaseDirectClient()
|
const pg = createSupabaseDirectClient()
|
||||||
|
|
||||||
const offset = page * limit
|
const offset = page * limit
|
||||||
const userId = auth?.uid
|
// const userId = auth?.uid
|
||||||
const searchFollowersSQL = getSearchUserSQL({ term, offset, limit, userId })
|
// const searchFollowersSQL = getSearchUserSQL({ term, offset, limit, userId })
|
||||||
const searchAllSQL = getSearchUserSQL({ term, offset, limit })
|
const searchAllSQL = getSearchUserSQL({term, offset, limit})
|
||||||
const [followers, all] = await Promise.all([
|
const [all] = await Promise.all([
|
||||||
pg.map(searchFollowersSQL, null, convertUser),
|
// pg.map(searchFollowersSQL, null, convertUser),
|
||||||
pg.map(searchAllSQL, null, convertUser),
|
pg.map(searchAllSQL, null, convertUser),
|
||||||
])
|
])
|
||||||
|
|
||||||
return uniqBy([...followers, ...all], 'id')
|
return uniqBy([...all], 'id')
|
||||||
.map(toUserAPIResponse)
|
.map(toUserAPIResponse)
|
||||||
.slice(0, limit)
|
.slice(0, limit)
|
||||||
}
|
}
|
||||||
@@ -39,31 +31,32 @@ function getSearchUserSQL(props: {
|
|||||||
limit: number
|
limit: number
|
||||||
userId?: string // search only this user's followers
|
userId?: string // search only this user's followers
|
||||||
}) {
|
}) {
|
||||||
const { term, userId } = props
|
const {term} = props
|
||||||
|
|
||||||
return renderSql(
|
return renderSql(
|
||||||
userId
|
// userId
|
||||||
? [
|
// ? [
|
||||||
select('users.*'),
|
// select('users.*'),
|
||||||
from('users'),
|
// from('users'),
|
||||||
join('user_follows on user_follows.follow_id = users.id'),
|
// join('user_follows on user_follows.follow_id = users.id'),
|
||||||
where('user_follows.user_id = $1', [userId]),
|
// where('user_follows.user_id = $1', [userId]),
|
||||||
]
|
// ]
|
||||||
: [select('*'), from('users')],
|
// :
|
||||||
|
[select('*'), from('users')],
|
||||||
term
|
term
|
||||||
? [
|
? [
|
||||||
where(
|
where(
|
||||||
`name_username_vector @@ websearch_to_tsquery('english', $1)
|
`name_username_vector @@ websearch_to_tsquery('english', $1)
|
||||||
or name_username_vector @@ to_tsquery('english', $2)`,
|
or name_username_vector @@ to_tsquery('english', $2)`,
|
||||||
[term, constructPrefixTsQuery(term)]
|
[term, constructPrefixTsQuery(term)]
|
||||||
),
|
),
|
||||||
|
|
||||||
orderBy(
|
orderBy(
|
||||||
`ts_rank(name_username_vector, websearch_to_tsquery($1)) desc,
|
`ts_rank(name_username_vector, websearch_to_tsquery($1)) desc,
|
||||||
data->>'lastBetTime' desc nulls last`,
|
data->>'lastBetTime' desc nulls last`,
|
||||||
[term]
|
[term]
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
: orderBy(`data->'creatorTraders'->'allTime' desc nulls last`),
|
: orderBy(`data->'creatorTraders'->'allTime' desc nulls last`),
|
||||||
limit(props.limit, props.offset)
|
limit(props.limit, props.offset)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {from, renderSql, select} from "shared/supabase/sql-builder";
|
|||||||
import {loadProfiles, profileQueryType} from "api/get-profiles";
|
import {loadProfiles, profileQueryType} from "api/get-profiles";
|
||||||
import {Row} from "common/supabase/utils";
|
import {Row} from "common/supabase/utils";
|
||||||
import {sendSearchAlertsEmail} from "email/functions/helpers";
|
import {sendSearchAlertsEmail} from "email/functions/helpers";
|
||||||
import {MatchesByUserType} from "common/love/bookmarked_searches";
|
import {MatchesByUserType} from "common/profiles/bookmarked_searches";
|
||||||
import {keyBy} from "lodash";
|
import {keyBy} from "lodash";
|
||||||
|
|
||||||
export function convertSearchRow(row: any): any {
|
export function convertSearchRow(row: any): any {
|
||||||
@@ -25,7 +25,7 @@ export const sendSearchNotifications = async () => {
|
|||||||
from('bookmarked_searches'),
|
from('bookmarked_searches'),
|
||||||
)
|
)
|
||||||
const searches = await pg.map(search_query, [], convertSearchRow) as Row<'bookmarked_searches'>[]
|
const searches = await pg.map(search_query, [], convertSearchRow) as Row<'bookmarked_searches'>[]
|
||||||
console.log(`Running ${searches.length} bookmarked searches`)
|
console.debug(`Running ${searches.length} bookmarked searches`)
|
||||||
|
|
||||||
const _users = await pg.map(
|
const _users = await pg.map(
|
||||||
renderSql(
|
renderSql(
|
||||||
@@ -36,7 +36,7 @@ export const sendSearchNotifications = async () => {
|
|||||||
convertSearchRow
|
convertSearchRow
|
||||||
) as Row<'users'>[]
|
) as Row<'users'>[]
|
||||||
const users = keyBy(_users, 'id')
|
const users = keyBy(_users, 'id')
|
||||||
console.log('users', users)
|
console.debug('users', users)
|
||||||
|
|
||||||
const _privateUsers = await pg.map(
|
const _privateUsers = await pg.map(
|
||||||
renderSql(
|
renderSql(
|
||||||
@@ -47,15 +47,21 @@ export const sendSearchNotifications = async () => {
|
|||||||
convertSearchRow
|
convertSearchRow
|
||||||
) as Row<'private_users'>[]
|
) as Row<'private_users'>[]
|
||||||
const privateUsers = keyBy(_privateUsers, 'id')
|
const privateUsers = keyBy(_privateUsers, 'id')
|
||||||
console.log('privateUsers', privateUsers)
|
console.debug('privateUsers', privateUsers)
|
||||||
|
|
||||||
const matches: MatchesByUserType = {}
|
const matches: MatchesByUserType = {}
|
||||||
|
|
||||||
for (const row of searches) {
|
for (const row of searches) {
|
||||||
if (typeof row.search_filters !== 'object') continue;
|
if (typeof row.search_filters !== 'object') continue;
|
||||||
const props = {...row.search_filters, skipId: row.creator_id, lastModificationWithin: '24 hours'}
|
const { orderBy: _, ...filters } = (row.search_filters ?? {}) as Record<string, any>
|
||||||
const profiles = await loadProfiles(props as profileQueryType)
|
const props = {
|
||||||
console.log(profiles.map((item: any) => item.name))
|
...filters,
|
||||||
|
skipId: row.creator_id,
|
||||||
|
lastModificationWithin: '24 hours',
|
||||||
|
shortBio: true,
|
||||||
|
}
|
||||||
|
const {profiles} = await loadProfiles(props as profileQueryType)
|
||||||
|
console.debug(profiles.map((item: any) => item.name))
|
||||||
if (!profiles.length) continue
|
if (!profiles.length) continue
|
||||||
if (!(row.creator_id in matches)) {
|
if (!(row.creator_id in matches)) {
|
||||||
if (!privateUsers[row.creator_id]) continue
|
if (!privateUsers[row.creator_id]) continue
|
||||||
@@ -74,7 +80,7 @@ export const sendSearchNotifications = async () => {
|
|||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
console.log('matches:', JSON.stringify(matches, null, 2))
|
console.debug('matches:', JSON.stringify(matches, null, 2))
|
||||||
await notifyBookmarkedSearch(matches)
|
await notifyBookmarkedSearch(matches)
|
||||||
|
|
||||||
return {status: 'success'}
|
return {status: 'success'}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
|
import "tsconfig-paths/register";
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import {getLocalEnv, initAdmin} from 'shared/init-admin'
|
import {initAdmin} from 'shared/init-admin'
|
||||||
import {loadSecretsToEnv, getServiceAccountCredentials} from 'common/secrets'
|
import {loadSecretsToEnv} from 'common/secrets'
|
||||||
import {log} from 'shared/utils'
|
import {log} from 'shared/utils'
|
||||||
import {LOCAL_DEV} from "common/envs/constants";
|
import {IS_LOCAL} from "common/hosting/constants";
|
||||||
import {METRIC_WRITER} from 'shared/monitoring/metric-writer'
|
import {METRIC_WRITER} from 'shared/monitoring/metric-writer'
|
||||||
import {listen as webSocketListen} from 'shared/websockets/server'
|
import {listen as webSocketListen} from 'shared/websockets/server'
|
||||||
|
|
||||||
log('Api server starting up....')
|
log('Api server starting up....')
|
||||||
|
|
||||||
if (LOCAL_DEV) {
|
if (IS_LOCAL) {
|
||||||
initAdmin()
|
initAdmin()
|
||||||
} else {
|
} else {
|
||||||
const projectId = process.env.GOOGLE_CLOUD_PROJECT
|
const projectId = process.env.GOOGLE_CLOUD_PROJECT
|
||||||
@@ -21,9 +22,10 @@ if (LOCAL_DEV) {
|
|||||||
METRIC_WRITER.start()
|
METRIC_WRITER.start()
|
||||||
|
|
||||||
import {app} from './app'
|
import {app} from './app'
|
||||||
|
import {getServiceAccountCredentials} from "shared/firebase-utils";
|
||||||
|
|
||||||
const credentials = LOCAL_DEV
|
const credentials = IS_LOCAL
|
||||||
? getServiceAccountCredentials(getLocalEnv())
|
? getServiceAccountCredentials()
|
||||||
: // No explicit credentials needed for deployed service.
|
: // No explicit credentials needed for deployed service.
|
||||||
undefined
|
undefined
|
||||||
|
|
||||||
@@ -37,6 +39,5 @@ const startupProcess = async () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
webSocketListen(httpServer, '/ws')
|
webSocketListen(httpServer, '/ws')
|
||||||
log('Server started successfully')
|
|
||||||
}
|
}
|
||||||
startupProcess()
|
startupProcess().then(_r => log('Server started successfully'))
|
||||||
|
|||||||
43
backend/api/src/set-compatibility-answer.ts
Normal file
43
backend/api/src/set-compatibility-answer.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import {APIHandler} from './helpers/endpoint'
|
||||||
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
import {Row} from 'common/supabase/utils'
|
||||||
|
import {recomputeCompatibilityScoresForUser} from 'shared/compatibility/compute-scores'
|
||||||
|
|
||||||
|
export const setCompatibilityAnswer: APIHandler<'set-compatibility-answer'> = async (
|
||||||
|
{questionId, multipleChoice, prefChoices, importance, explanation},
|
||||||
|
auth
|
||||||
|
) => {
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
|
|
||||||
|
const result = await pg.one<Row<'compatibility_answers'>>({
|
||||||
|
text: `
|
||||||
|
INSERT INTO compatibility_answers
|
||||||
|
(creator_id, question_id, multiple_choice, pref_choices, importance, explanation)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (question_id, creator_id)
|
||||||
|
DO UPDATE SET multiple_choice = EXCLUDED.multiple_choice,
|
||||||
|
pref_choices = EXCLUDED.pref_choices,
|
||||||
|
importance = EXCLUDED.importance,
|
||||||
|
explanation = EXCLUDED.explanation
|
||||||
|
RETURNING *
|
||||||
|
`,
|
||||||
|
values: [
|
||||||
|
auth.uid,
|
||||||
|
questionId,
|
||||||
|
multipleChoice,
|
||||||
|
prefChoices,
|
||||||
|
importance,
|
||||||
|
explanation ?? null,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const continuation = async () => {
|
||||||
|
// Recompute precomputed compatibility scores for this user
|
||||||
|
await recomputeCompatibilityScoresForUser(auth.uid, pg)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
result: result,
|
||||||
|
continue: continuation,
|
||||||
|
}
|
||||||
|
}
|
||||||
26
backend/api/src/set-last-online-time.ts
Normal file
26
backend/api/src/set-last-online-time.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import {APIHandler} from './helpers/endpoint'
|
||||||
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
|
||||||
|
export const setLastOnlineTime: APIHandler<'set-last-online-time'> = async (
|
||||||
|
_,
|
||||||
|
auth
|
||||||
|
) => {
|
||||||
|
if (!auth || !auth.uid) return
|
||||||
|
await setLastOnlineTimeUser(auth.uid)
|
||||||
|
// console.log('setLastOnline')
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const setLastOnlineTimeUser = async (userId: string) => {
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
|
await pg.none(`
|
||||||
|
INSERT INTO user_activity (user_id, last_online_time)
|
||||||
|
VALUES ($1, now())
|
||||||
|
ON CONFLICT (user_id)
|
||||||
|
DO UPDATE
|
||||||
|
SET last_online_time = EXCLUDED.last_online_time
|
||||||
|
WHERE user_activity.last_online_time < now() - interval '1 minute';
|
||||||
|
`,
|
||||||
|
[userId]
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||||
import { APIError, APIHandler } from './helpers/endpoint'
|
import { APIError, APIHandler } from './helpers/endpoint'
|
||||||
import { createLoveShipNotification } from 'shared/create-love-notification'
|
import { createProfileShipNotification } from 'shared/create-profile-notification'
|
||||||
import { log } from 'shared/utils'
|
import { log } from 'shared/utils'
|
||||||
import { tryCatch } from 'common/util/try-catch'
|
import { tryCatch } from 'common/util/try-catch'
|
||||||
import { insert } from 'shared/supabase/utils'
|
import { insert } from 'shared/supabase/utils'
|
||||||
@@ -14,7 +14,7 @@ export const shipProfiles: APIHandler<'ship-profiles'> = async (props, auth) =>
|
|||||||
// Check if ship already exists or with swapped target IDs
|
// Check if ship already exists or with swapped target IDs
|
||||||
const existing = await tryCatch(
|
const existing = await tryCatch(
|
||||||
pg.oneOrNone<{ ship_id: string }>(
|
pg.oneOrNone<{ ship_id: string }>(
|
||||||
`select ship_id from love_ships
|
`select ship_id from profile_ships
|
||||||
where creator_id = $1
|
where creator_id = $1
|
||||||
and (
|
and (
|
||||||
target1_id = $2 and target2_id = $3
|
target1_id = $2 and target2_id = $3
|
||||||
@@ -33,7 +33,7 @@ export const shipProfiles: APIHandler<'ship-profiles'> = async (props, auth) =>
|
|||||||
if (existing.data) {
|
if (existing.data) {
|
||||||
if (remove) {
|
if (remove) {
|
||||||
const { error } = await tryCatch(
|
const { error } = await tryCatch(
|
||||||
pg.none('delete from love_ships where ship_id = $1', [
|
pg.none('delete from profile_ships where ship_id = $1', [
|
||||||
existing.data.ship_id,
|
existing.data.ship_id,
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
@@ -48,7 +48,7 @@ export const shipProfiles: APIHandler<'ship-profiles'> = async (props, auth) =>
|
|||||||
|
|
||||||
// Insert the new ship
|
// Insert the new ship
|
||||||
const { data, error } = await tryCatch(
|
const { data, error } = await tryCatch(
|
||||||
insert(pg, 'love_ships', {
|
insert(pg, 'profile_ships', {
|
||||||
creator_id: creatorId,
|
creator_id: creatorId,
|
||||||
target1_id: targetUserId1,
|
target1_id: targetUserId1,
|
||||||
target2_id: targetUserId2,
|
target2_id: targetUserId2,
|
||||||
@@ -61,8 +61,8 @@ export const shipProfiles: APIHandler<'ship-profiles'> = async (props, auth) =>
|
|||||||
|
|
||||||
const continuation = async () => {
|
const continuation = async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
createLoveShipNotification(data, data.target1_id),
|
createProfileShipNotification(data, data.target1_id),
|
||||||
createLoveShipNotification(data, data.target2_id),
|
createProfileShipNotification(data, data.target2_id),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const starProfile: APIHandler<'star-profile'> = async (props, auth) => {
|
|||||||
if (remove) {
|
if (remove) {
|
||||||
const { error } = await tryCatch(
|
const { error } = await tryCatch(
|
||||||
pg.none(
|
pg.none(
|
||||||
'delete from love_stars where creator_id = $1 and target_id = $2',
|
'delete from profile_stars where creator_id = $1 and target_id = $2',
|
||||||
[creatorId, targetUserId]
|
[creatorId, targetUserId]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -27,8 +27,8 @@ export const starProfile: APIHandler<'star-profile'> = async (props, auth) => {
|
|||||||
|
|
||||||
// Check if star already exists
|
// Check if star already exists
|
||||||
const { data: existing } = await tryCatch(
|
const { data: existing } = await tryCatch(
|
||||||
pg.oneOrNone<Row<'love_stars'>>(
|
pg.oneOrNone<Row<'profile_stars'>>(
|
||||||
'select * from love_stars where creator_id = $1 and target_id = $2',
|
'select * from profile_stars where creator_id = $1 and target_id = $2',
|
||||||
[creatorId, targetUserId]
|
[creatorId, targetUserId]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -40,7 +40,7 @@ export const starProfile: APIHandler<'star-profile'> = async (props, auth) => {
|
|||||||
|
|
||||||
// Insert the new star
|
// Insert the new star
|
||||||
const { error } = await tryCatch(
|
const { error } = await tryCatch(
|
||||||
insert(pg, 'love_stars', { creator_id: creatorId, target_id: targetUserId })
|
insert(pg, 'profile_stars', { creator_id: creatorId, target_id: targetUserId })
|
||||||
)
|
)
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
8
backend/api/src/test.ts
Normal file
8
backend/api/src/test.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import {sendTestEmail} from "email/functions/helpers";
|
||||||
|
|
||||||
|
export const localSendTestEmail = async () => {
|
||||||
|
sendTestEmail('hello@compassmeet.com')
|
||||||
|
.then(() => console.debug('Email sent successfully!'))
|
||||||
|
.catch((error) => console.error('Failed to send email:', error))
|
||||||
|
return { message: 'Email sent successfully!'}
|
||||||
|
}
|
||||||
63
backend/api/src/update-options.ts
Normal file
63
backend/api/src/update-options.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import {APIError, APIHandler} from 'api/helpers/endpoint'
|
||||||
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
import {log} from 'shared/utils'
|
||||||
|
import {tryCatch} from 'common/util/try-catch'
|
||||||
|
import {OPTION_TABLES} from "common/profiles/constants";
|
||||||
|
|
||||||
|
export const updateOptions: APIHandler<'update-options'> = async (
|
||||||
|
{table, names},
|
||||||
|
auth
|
||||||
|
) => {
|
||||||
|
if (!OPTION_TABLES.includes(table)) throw new APIError(400, 'Invalid table')
|
||||||
|
if (!names || !Array.isArray(names) || names.length === 0) {
|
||||||
|
throw new APIError(400, 'No names provided')
|
||||||
|
}
|
||||||
|
|
||||||
|
log('Updating profile options', {table, names})
|
||||||
|
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
|
|
||||||
|
const profileIdResult = await pg.oneOrNone<{ id: number }>(
|
||||||
|
'SELECT id FROM profiles WHERE user_id = $1',
|
||||||
|
[auth.uid]
|
||||||
|
)
|
||||||
|
if (!profileIdResult) throw new APIError(404, 'Profile not found')
|
||||||
|
const profileId = profileIdResult.id
|
||||||
|
|
||||||
|
const result = await tryCatch(pg.tx(async (t) => {
|
||||||
|
const ids: number[] = []
|
||||||
|
for (const name of names) {
|
||||||
|
const row = await t.one<{ id: number }>(
|
||||||
|
`INSERT INTO ${table} (name, creator_id)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (name) DO UPDATE
|
||||||
|
SET name = ${table}.name
|
||||||
|
RETURNING id`,
|
||||||
|
[name, auth.uid]
|
||||||
|
)
|
||||||
|
ids.push(row.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete old options for this profile
|
||||||
|
await t.none(`DELETE FROM profile_${table} WHERE profile_id = $1`, [profileId])
|
||||||
|
|
||||||
|
// Insert new option_ids
|
||||||
|
if (ids.length > 0) {
|
||||||
|
const values = ids.map((id, i) => `($1, $${i + 2})`).join(', ')
|
||||||
|
await t.none(
|
||||||
|
`INSERT INTO profile_${table} (profile_id, option_id) VALUES ${values}`,
|
||||||
|
[profileId, ...ids]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ids
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (result.error) {
|
||||||
|
log('Error updating profile options', result.error)
|
||||||
|
throw new APIError(500, 'Error updating profile options')
|
||||||
|
}
|
||||||
|
|
||||||
|
return {updatedIds: result.data}
|
||||||
|
}
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user