mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-04-04 14:53:33 -04:00
Compare commits
381 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
23
.env.example
23
.env.example
@@ -1,20 +1,7 @@
|
|||||||
# Rename this file to `.env` and fill in the values.
|
|
||||||
# You already have access to basic local functionality (UI, authentication, database read access).
|
# You already have access to basic local functionality (UI, authentication, database read access).
|
||||||
|
|
||||||
# Optional variables for the backend server functionality (modifying user data, etc.)
|
# openssl enc -aes-256-cbc -salt -pbkdf2 -iter 100000 -in backend/shared/src/googleApplicationCredentials-dev.json -out secrets/googleApplicationCredentials-dev.json.enc
|
||||||
|
GOOGLE_CREDENTIALS_ENC_PWD=nP7s3274uzOG4c2t
|
||||||
# For database write access (dev).
|
|
||||||
# A 16-character password with digits and letters.
|
|
||||||
SUPABASE_DB_PASSWORD=09wATRREfAzyL5pc
|
|
||||||
|
|
||||||
# For Firebase access.
|
|
||||||
# Open a GitHub issue with your contribution ideas and an admin will give you the key.
|
|
||||||
# TODO: find a way to give anyone moderate access to dev firebase.
|
|
||||||
GOOGLE_APPLICATION_CREDENTIALS_DEV="[...].json"
|
|
||||||
|
|
||||||
# The URL where your local backend server is running.
|
|
||||||
# You can change the port if needed.
|
|
||||||
NEXT_PUBLIC_API_URL=localhost:8088
|
|
||||||
|
|
||||||
|
|
||||||
# Optional variables for full local functionality
|
# Optional variables for full local functionality
|
||||||
@@ -23,10 +10,6 @@ NEXT_PUBLIC_API_URL=localhost:8088
|
|||||||
# Create a free account at https://rapidapi.com/wirefreethought/api/geodb-cities and get an API key.
|
# Create a free account at https://rapidapi.com/wirefreethought/api/geodb-cities and get an API key.
|
||||||
GEODB_API_KEY=
|
GEODB_API_KEY=
|
||||||
|
|
||||||
# For analytics like page views, user actions, feature usage, etc.
|
|
||||||
# Create a free account at https://posthog.com and get a project API key. Should start with "phc_".
|
|
||||||
POSTHOG_KEY=
|
|
||||||
|
|
||||||
# For sending emails (e.g. for user sign up, password reset, notifications, etc.).
|
# For sending emails (e.g. for user sign up, password reset, notifications, etc.).
|
||||||
# Create a free account at https://resend.com and get an API key. Should start with "re_".
|
# Create a free account at https://resend.com and get an API key. Should start with "re_".
|
||||||
RESEND_API_KEY=
|
RESEND_KEY=
|
||||||
|
|||||||
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: # 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']
|
||||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -68,6 +68,7 @@ email-preview
|
|||||||
*.jpeg
|
*.jpeg
|
||||||
*.gif
|
*.gif
|
||||||
*.svg
|
*.svg
|
||||||
|
*.ico
|
||||||
*.mp4
|
*.mp4
|
||||||
*.mov
|
*.mov
|
||||||
*.avi
|
*.avi
|
||||||
@@ -79,3 +80,10 @@ 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/
|
||||||
|
|||||||
56
README.md
56
README.md
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[](https://github.com/CompassConnections/Compass/actions/workflows/ci.yml)
|
[](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)
|
||||||

|

|
||||||
|
|
||||||
# Compass
|
# Compass
|
||||||
|
|
||||||
@@ -21,11 +21,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).
|
||||||
|
|
||||||
|
<p style="text-align: center;">
|
||||||
|
<img src="https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/misc%2Fdemo_compass.gif?alt=media&token=e3ae4334-4e3f-4026-b121-c08b4b724cd1" alt="Compass Demo" width="600">
|
||||||
|
</p>
|
||||||
|
|
||||||
## 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
|
||||||
@@ -49,16 +66,16 @@ 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.)
|
- [ ] Add profile fields (intellectual interests, cause areas, personality type, conflict style, timezone, etc.)
|
||||||
- [ ] Add filters to search through remaining profile features (politics, religion, education level, etc.)
|
- [ ] Add filters to search through remaining profile fields (politics, religion, education level, etc.)
|
||||||
- [ ] Cover with tests (very important, just the test template and framework are ready)
|
- [ ] Cover with tests (crucial, just the test template and framework are ready)
|
||||||
- [ ] 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
|
- [ ] Add email verification
|
||||||
- [ ] Add password reset
|
- [ ] 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
|
- [ ] Make `deploy-api.sh` run automatically on push to `main` branch
|
||||||
- [ ] Create settings page (change email, password, delete account, etc.)
|
- [ ] Create settings page (change email, password, delete account, etc.)
|
||||||
@@ -100,20 +117,17 @@ yarn install
|
|||||||
|
|
||||||
### Environment Variables
|
### 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:
|
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)
|
- Database, otherwise anyone could access all the user data (including private messages)
|
||||||
- Firebase, otherwise anyone could remove users or modify the media files
|
- Firebase, otherwise anyone could remove users or modify the media files
|
||||||
- Email, analytics, and location services, otherwise anyone could use our paid plan
|
- Email, analytics, and location services, otherwise anyone could use the service plans Compass paid for and run up the bill.
|
||||||
|
|
||||||
We separate all those services between production and local development, so that you can code freely without impacting the functioning of the platform.
|
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.
|
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`:
|
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.
|
||||||
```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
|
||||||
|
|
||||||
@@ -132,11 +146,23 @@ 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/`.
|
||||||
|
|
||||||
|
See [development.md](docs/development.md) for additional instructions, such as adding new profile fields.
|
||||||
|
|
||||||
### Submission
|
### Submission
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {aColor, supportEmail} from "@/lib/client/constants";
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
|
||||||
export default function PrivacyPage() {
|
export default function PrivacyPage() {
|
||||||
2
_old/lib/client/constants.tsx
Normal file
2
_old/lib/client/constants.tsx
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
@@ -54,6 +54,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:
|
||||||
@@ -158,5 +161,4 @@ 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!
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
# }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,21 +4,23 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"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",
|
||||||
|
"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)",
|
"compile": "tsc -b && tsc-alias && (cd ../../common && tsc-alias) && (cd ../shared && tsc-alias) && (cd ../email && tsc-alias)",
|
||||||
"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",
|
||||||
"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"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
@@ -44,29 +46,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,7 @@ 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 {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 +20,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 +40,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 +50,28 @@ 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 {z, ZodFirstPartyTypeKind, ZodTypeAny} from "zod";
|
||||||
|
import {getUser} from "api/get-user";
|
||||||
|
|
||||||
|
// 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 +119,200 @@ const apiErrorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
|
|||||||
export const app = express()
|
export const app = express()
|
||||||
app.use(requestMonitoring)
|
app.use(requestMonitoring)
|
||||||
|
|
||||||
const swaggerDocument = JSON.parse(fs.readFileSync("./openapi.json", "utf-8"))
|
const schemaCache = new WeakMap<ZodTypeAny, any>();
|
||||||
swaggerDocument.info = {
|
|
||||||
...swaggerDocument.info,
|
export function zodToOpenApiSchema(
|
||||||
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.",
|
zodObj: ZodTypeAny,
|
||||||
version: "1.0.0",
|
nameHint?: string
|
||||||
contact: {
|
): any { // Prevent infinite recursion
|
||||||
name: "Compass",
|
if (schemaCache.has(zodObj)) {
|
||||||
email: "compass.meet.info@gmail.com",
|
return schemaCache.get(zodObj);
|
||||||
url: "https://compassmeet.com"
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
const def: any = (zodObj as any)._def;
|
||||||
|
const typeName = def.typeName as ZodFirstPartyTypeKind;
|
||||||
|
|
||||||
|
// Placeholder so recursive references can point here
|
||||||
|
const placeholder: any = {};
|
||||||
|
schemaCache.set(zodObj, placeholder);
|
||||||
|
|
||||||
|
let schema: any;
|
||||||
|
|
||||||
|
switch (typeName) {
|
||||||
|
case 'ZodString':
|
||||||
|
schema = { type: 'string' };
|
||||||
|
break;
|
||||||
|
case 'ZodNumber':
|
||||||
|
schema = { type: 'number' };
|
||||||
|
break;
|
||||||
|
case 'ZodBoolean':
|
||||||
|
schema = { type: 'boolean' };
|
||||||
|
break;
|
||||||
|
case 'ZodEnum':
|
||||||
|
schema = { type: 'string', enum: def.values };
|
||||||
|
break;
|
||||||
|
case 'ZodArray':
|
||||||
|
schema = { type: 'array', items: zodToOpenApiSchema(def.type) };
|
||||||
|
break;
|
||||||
|
case 'ZodObject': {
|
||||||
|
const shape = def.shape();
|
||||||
|
const properties: Record<string, any> = {};
|
||||||
|
const required: string[] = [];
|
||||||
|
|
||||||
|
for (const key in shape) {
|
||||||
|
const child = shape[key];
|
||||||
|
properties[key] = zodToOpenApiSchema(child, key);
|
||||||
|
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':
|
||||||
|
// Recursive schema: use a $ref placeholder name
|
||||||
|
schema = {
|
||||||
|
$ref: `#/components/schemas/${nameHint ?? 'RecursiveType'}`,
|
||||||
|
};
|
||||||
|
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.",
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
} as OpenAPIV3.Document;
|
||||||
|
|
||||||
|
|
||||||
const rootPath = pathWithPrefix("/")
|
const rootPath = pathWithPrefix("/")
|
||||||
app.get(rootPath, swaggerUi.setup(swaggerDocument))
|
app.get(rootPath, swaggerUi.setup(swaggerDocument))
|
||||||
app.use(rootPath, swaggerUi.serve)
|
app.use(rootPath, swaggerUi.serve)
|
||||||
|
|
||||||
app.options('*', allowCorsUnrestricted)
|
// 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 +338,10 @@ 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,
|
||||||
|
'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 +350,14 @@ 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,
|
||||||
|
'create-bookmarked-search': createBookmarkedSearch,
|
||||||
|
'delete-bookmarked-search': deleteBookmarkedSearch,
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.entries(handlers).forEach(([path, handler]) => {
|
Object.entries(handlers).forEach(([path, handler]) => {
|
||||||
@@ -191,8 +385,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,6 +398,10 @@ 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"});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { groupBy, sortBy } from 'lodash'
|
import { groupBy, sortBy } from 'lodash'
|
||||||
import { APIError, type APIHandler } from 'api/helpers/endpoint'
|
import { APIError, type APIHandler } from 'api/helpers/endpoint'
|
||||||
import { getCompatibilityScore } from 'common/love/compatibility-score'
|
import { getCompatibilityScore } from 'common/profiles/compatibility-score'
|
||||||
import {
|
import {
|
||||||
getProfile,
|
getProfile,
|
||||||
getCompatibilityAnswers,
|
getCompatibilityAnswers,
|
||||||
getGenderCompatibleProfiles,
|
getGenderCompatibleProfiles,
|
||||||
} from 'shared/love/supabase'
|
} from 'shared/profiles/supabase'
|
||||||
import { log } from 'shared/utils'
|
import { log } from 'shared/utils'
|
||||||
|
|
||||||
export const getCompatibleProfilesHandler: APIHandler<
|
export const getCompatibleProfilesHandler: APIHandler<
|
||||||
|
|||||||
41
backend/api/src/contact.ts
Normal file
41
backend/api/src/contact.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
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 {
|
||||||
|
const md = jsonToMarkdown(content)
|
||||||
|
const message: string = `**New Contact Message**\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',
|
||||||
|
|||||||
76
backend/api/src/create-notification.ts
Normal file
76
backend/api/src/create-notification.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
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 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,
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ 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 } 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 })
|
||||||
@@ -40,7 +42,51 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
log('Created user', data)
|
log('Created user', 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 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,27 @@
|
|||||||
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 {IS_LOCAL, 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";
|
||||||
|
|
||||||
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 +52,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 +63,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 +73,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 +83,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 +122,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 +151,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',
|
||||||
|
|||||||
27
backend/api/src/create-vote.ts
Normal file
27
backend/api/src/create-vote.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
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,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
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 {}
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
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 (body, auth) => {
|
||||||
const { username } = body
|
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')
|
||||||
@@ -16,13 +16,30 @@ export const deleteMe: APIHandler<'me/delete'> = async (body, auth) => {
|
|||||||
`Incorrect username. You are logged in as ${user.username}. Are you sure you want to delete this account?`
|
`Incorrect username. You are logged in as ${user.username}. Are you sure you want to delete this account?`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
const userId = user.id
|
||||||
|
if (!userId) {
|
||||||
|
throw new APIError(400, 'Invalid user ID')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
// await pg.none('DELETE FROM private_users WHERE id = $1', [userId])
|
||||||
})
|
// await pg.none('DELETE FROM profiles WHERE user_id = $1', [userId])
|
||||||
await updatePrivateUser(pg, auth.uid, {
|
// await pg.none('DELETE FROM bookmarked_searches WHERE creator_id = $1', [userId])
|
||||||
email: FieldVal.delete(),
|
// await pg.none('DELETE FROM compatibility_answers WHERE creator_id = $1', [userId])
|
||||||
})
|
// May need to also delete from other tables in the future (such as messages, compatibility responses, etc.)
|
||||||
|
|
||||||
|
// Delete user files from Firebase Storage
|
||||||
|
await deleteUserFiles(user.username)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,10 +1,10 @@
|
|||||||
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} 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 {getCompatibleProfiles} from 'api/compatible-profiles'
|
||||||
import {intersection} from 'lodash'
|
import {intersection} from 'lodash'
|
||||||
import {MAX_INT, MIN_INT} from "common/constants";
|
import {MAX_INT, MIN_BIO_LENGTH, MIN_INT} from "common/constants";
|
||||||
|
|
||||||
export type profileQueryType = {
|
export type profileQueryType = {
|
||||||
limit?: number | undefined,
|
limit?: number | undefined,
|
||||||
@@ -12,43 +12,67 @@ export type profileQueryType = {
|
|||||||
// Search and filter parameters
|
// Search and filter parameters
|
||||||
name?: string | undefined,
|
name?: string | undefined,
|
||||||
genders?: String[] | undefined,
|
genders?: String[] | undefined,
|
||||||
|
education_levels?: String[] | undefined,
|
||||||
pref_gender?: 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,
|
||||||
|
drinks_min?: number | undefined,
|
||||||
|
drinks_max?: number | undefined,
|
||||||
pref_relation_styles?: String[] | undefined,
|
pref_relation_styles?: String[] | undefined,
|
||||||
|
pref_romantic_styles?: String[] | undefined,
|
||||||
|
diet?: String[] | undefined,
|
||||||
|
political_beliefs?: 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,
|
||||||
|
shortBio?: boolean | undefined,
|
||||||
geodbCityIds?: String[] | 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(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,
|
||||||
wants_kids_strength,
|
wants_kids_strength,
|
||||||
has_kids,
|
has_kids,
|
||||||
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)
|
||||||
|
|
||||||
@@ -64,13 +88,24 @@ export const loadProfiles = async (props: profileQueryType) => {
|
|||||||
(l) =>
|
(l) =>
|
||||||
(!name || l.user.name.toLowerCase().includes(name.toLowerCase())) &&
|
(!name || l.user.name.toLowerCase().includes(name.toLowerCase())) &&
|
||||||
(!genders || genders.includes(l.gender)) &&
|
(!genders || genders.includes(l.gender)) &&
|
||||||
|
(!education_levels || education_levels.includes(l.education_level ?? '')) &&
|
||||||
(!pref_gender || intersection(pref_gender, l.pref_gender).length) &&
|
(!pref_gender || intersection(pref_gender, l.pref_gender).length) &&
|
||||||
(!pref_age_min || (l.age ?? MAX_INT) >= pref_age_min) &&
|
(!pref_age_min || (l.age ?? MAX_INT) >= pref_age_min) &&
|
||||||
(!pref_age_max || (l.age ?? MIN_INT) <= pref_age_max) &&
|
(!pref_age_max || (l.age ?? MIN_INT) <= pref_age_max) &&
|
||||||
|
(!drinks_min || (l.drinks_per_month ?? MAX_INT) >= drinks_min) &&
|
||||||
|
(!drinks_max || (l.drinks_per_month ?? MIN_INT) <= drinks_max) &&
|
||||||
(!pref_relation_styles ||
|
(!pref_relation_styles ||
|
||||||
intersection(pref_relation_styles, l.pref_relation_styles).length) &&
|
intersection(pref_relation_styles, l.pref_relation_styles).length) &&
|
||||||
|
(!pref_romantic_styles ||
|
||||||
|
intersection(pref_romantic_styles, l.pref_romantic_styles).length) &&
|
||||||
|
(!diet ||
|
||||||
|
intersection(diet, l.diet).length) &&
|
||||||
|
(!political_beliefs ||
|
||||||
|
intersection(political_beliefs, l.political_beliefs).length) &&
|
||||||
(!wants_kids_strength ||
|
(!wants_kids_strength ||
|
||||||
wants_kids_strength == -1 ||
|
wants_kids_strength == -1 ||
|
||||||
|
!l.wants_kids_strength ||
|
||||||
|
l.wants_kids_strength == -1 ||
|
||||||
(wants_kids_strength >= 2
|
(wants_kids_strength >= 2
|
||||||
? l.wants_kids_strength >= wants_kids_strength
|
? l.wants_kids_strength >= wants_kids_strength
|
||||||
: l.wants_kids_strength <= wants_kids_strength)) &&
|
: l.wants_kids_strength <= wants_kids_strength)) &&
|
||||||
@@ -78,26 +113,37 @@ export const loadProfiles = async (props: profileQueryType) => {
|
|||||||
has_kids == -1 ||
|
has_kids == -1 ||
|
||||||
(has_kids == 0 && !l.has_kids) ||
|
(has_kids == 0 && !l.has_kids) ||
|
||||||
(l.has_kids && l.has_kids > 0)) &&
|
(l.has_kids && l.has_kids > 0)) &&
|
||||||
(!is_smoker || l.is_smoker === is_smoker) &&
|
(is_smoker === undefined || l.is_smoker === is_smoker) &&
|
||||||
(l.id.toString() != skipId) &&
|
(l.id.toString() != skipId) &&
|
||||||
(!geodbCityIds ||
|
(!geodbCityIds ||
|
||||||
(l.geodb_city_id && geodbCityIds.includes(l.geodb_city_id)))
|
(l.geodb_city_id && geodbCityIds.includes(l.geodb_city_id))) &&
|
||||||
|
(!filterLocation ||(
|
||||||
|
l.city_latitude && l.city_longitude &&
|
||||||
|
Math.abs(l.city_latitude - lat) < radius / 69.0 &&
|
||||||
|
Math.abs(l.city_longitude - lon) < radius / (69.0 * Math.cos(lat * Math.PI / 180)) &&
|
||||||
|
Math.pow(l.city_latitude - lat, 2) + Math.pow((l.city_longitude - lon) * Math.cos(lat * Math.PI / 180), 2) < Math.pow(radius / 69.0, 2)
|
||||||
|
)) &&
|
||||||
|
(shortBio || (l.bio_length ?? 0) >= MIN_BIO_LENGTH)
|
||||||
)
|
)
|
||||||
|
|
||||||
const cursor = after
|
const cursor = after
|
||||||
? profiles.findIndex((l) => l.id.toString() === after) + 1
|
? profiles.findIndex((l) => l.id.toString() === after) + 1
|
||||||
: 0
|
: 0
|
||||||
console.log(cursor)
|
console.debug(cursor)
|
||||||
|
|
||||||
if (limitParam) return profiles.slice(cursor, cursor + limitParam)
|
if (limitParam) return profiles.slice(cursor, cursor + limitParam)
|
||||||
|
|
||||||
return profiles
|
return profiles
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tablePrefix = userActivityColumns.includes(orderByParam) ? 'user_activity' : 'profiles'
|
||||||
|
const userActivityJoin = 'user_activity on user_activity.user_id = profiles.user_id'
|
||||||
|
|
||||||
const query = renderSql(
|
const query = renderSql(
|
||||||
select('profiles.*, name, username, users.data as user'),
|
select('profiles.*, name, username, users.data as user, user_activity.last_online_time'),
|
||||||
from('profiles'),
|
from('profiles'),
|
||||||
join('users on users.id = profiles.user_id'),
|
join('users on users.id = profiles.user_id'),
|
||||||
|
leftJoin(userActivityJoin),
|
||||||
where('looking_for_matches = true'),
|
where('looking_for_matches = true'),
|
||||||
// where(`pinned_url is not null and pinned_url != ''`),
|
// where(`pinned_url is not null and pinned_url != ''`),
|
||||||
where(
|
where(
|
||||||
@@ -106,14 +152,16 @@ 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}),
|
||||||
|
|
||||||
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,44 +169,90 @@ 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}
|
||||||
),
|
),
|
||||||
|
|
||||||
!!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}),
|
||||||
|
|
||||||
|
orderBy(`${tablePrefix}.${orderByParam} DESC`),
|
||||||
after &&
|
after &&
|
||||||
where(
|
where(
|
||||||
`profiles.${orderByParam} < (select profiles.${orderByParam} from profiles where id = $(after))`,
|
`${tablePrefix}.${orderByParam} < (
|
||||||
|
SELECT ${tablePrefix}.${orderByParam}
|
||||||
|
FROM profiles
|
||||||
|
LEFT JOIN ${userActivityJoin}
|
||||||
|
WHERE profiles.id = $(after)
|
||||||
|
)`,
|
||||||
{after}
|
{after}
|
||||||
),
|
),
|
||||||
|
|
||||||
|
!shortBio && where(`bio_length >= ${MIN_BIO_LENGTH}`, {MIN_BIO_LENGTH}),
|
||||||
|
|
||||||
lastModificationWithin && where(`last_modification_time >= NOW() - INTERVAL $(lastModificationWithin)`, {lastModificationWithin}),
|
lastModificationWithin && where(`last_modification_time >= NOW() - INTERVAL $(lastModificationWithin)`, {lastModificationWithin}),
|
||||||
|
|
||||||
limitParam && limit(limitParam)
|
limitParam && limit(limitParam)
|
||||||
)
|
)
|
||||||
|
|
||||||
// console.log('query:', query)
|
// console.debug('query:', query)
|
||||||
|
|
||||||
return await pg.map(query, [], convertRow)
|
return await pg.map(query, [], convertRow)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,17 +17,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,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,14 @@ 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) {
|
||||||
// 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 +93,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 +101,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 +126,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 +174,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 +240,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 +272,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) {
|
||||||
|
|||||||
260
backend/api/src/helpers/private-messages.ts
Normal file
260
backend/api/src/helpers/private-messages.ts
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
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";
|
||||||
|
|
||||||
|
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 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 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}`, {})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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 otherUserId = first(otherUserIds)
|
||||||
|
if (!otherUserId) return
|
||||||
|
|
||||||
|
// TODO: notification only for active user
|
||||||
|
|
||||||
|
const otherUser = await getUser(otherUserId.user_id)
|
||||||
|
console.debug('otherUser:', otherUser)
|
||||||
|
if (!otherUser) return
|
||||||
|
|
||||||
|
// Push notif
|
||||||
|
webPush.setVapidDetails(
|
||||||
|
'mailto:hello@compassmeet.com',
|
||||||
|
process.env.VAPID_PUBLIC_KEY!,
|
||||||
|
process.env.VAPID_PRIVATE_KEY!
|
||||||
|
);
|
||||||
|
const textContent = parseJsonContentToText(content)
|
||||||
|
// Retrieve subscription from the database
|
||||||
|
const subscriptions = await getSubscriptionsFromDB(otherUser.id, pg);
|
||||||
|
for (const subscription of subscriptions) {
|
||||||
|
try {
|
||||||
|
const payload = JSON.stringify({
|
||||||
|
title: `${creator.name}`,
|
||||||
|
body: textContent,
|
||||||
|
url: `/messages/${channelId}`,
|
||||||
|
})
|
||||||
|
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 pg.none(
|
||||||
|
`DELETE
|
||||||
|
FROM push_subscriptions
|
||||||
|
WHERE endpoint = $1
|
||||||
|
AND user_id = $2`,
|
||||||
|
[subscription.endpoint, otherUser.id]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.error('Push failed', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
await createNewMessageNotification(creator, otherUser, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function getSubscriptionsFromDB(
|
||||||
|
userId: string,
|
||||||
|
pg: SupabaseDirectClient
|
||||||
|
) {
|
||||||
|
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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user