mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-04-04 23:03:45 -04:00
Compare commits
318 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 |
4
.github/FUNDING.yml
vendored
4
.github/FUNDING.yml
vendored
@@ -1,8 +1,8 @@
|
|||||||
# These are supported funding model platforms
|
# These are supported funding model platforms
|
||||||
|
|
||||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
github: [CompassConnections] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||||
patreon: CompassMeet # Replace with a single Patreon username
|
patreon: CompassMeet # Replace with a single Patreon username
|
||||||
open_collective: # Replace with a single Open Collective username
|
open_collective: compass-connection # Replace with a single Open Collective username
|
||||||
ko_fi: compassconnections # Replace with a single Ko-fi 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
|
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
|
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -68,6 +68,7 @@ email-preview
|
|||||||
*.jpeg
|
*.jpeg
|
||||||
*.gif
|
*.gif
|
||||||
*.svg
|
*.svg
|
||||||
|
*.ico
|
||||||
*.mp4
|
*.mp4
|
||||||
*.mov
|
*.mov
|
||||||
*.avi
|
*.avi
|
||||||
@@ -84,4 +85,5 @@ email-preview
|
|||||||
*.tfstate
|
*.tfstate
|
||||||
*.tfstate.backup
|
*.tfstate.backup
|
||||||
*.terraform
|
*.terraform
|
||||||
|
/backups/firebase/auth/data/
|
||||||
/backups/firebase/storage/data/
|
/backups/firebase/storage/data/
|
||||||
|
|||||||
43
README.md
43
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.)
|
||||||
@@ -105,7 +122,7 @@ Almost all the features will work out of the box, so you can skip this step and
|
|||||||
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.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
@@ -135,7 +152,17 @@ Note: it's normal if page loading locally is much slower than the deployed versi
|
|||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
|||||||
@@ -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,12 +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 {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()
|
||||||
@@ -29,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 })
|
||||||
@@ -46,15 +47,17 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
|
|||||||
try {
|
try {
|
||||||
await track(auth.uid, 'create profile', {username: user.username})
|
await track(auth.uid, 'create profile', {username: user.username})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('Failed to track create profile', e)
|
console.error('Failed to track create profile', e)
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await sendDiscordMessage(
|
let message: string = `[**${user.name}**](https://www.compassmeet.com/${user.username}) just created a profile`
|
||||||
`**${user.name}** just created a profile at https://www.compassmeet.com/${user.username}`,
|
if (body.bio) {
|
||||||
'members',
|
const bioText = jsonToMarkdown(body.bio)
|
||||||
)
|
if (bioText) message += `\n${bioText}`
|
||||||
|
}
|
||||||
|
await sendDiscordMessage(message, 'members')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('Failed to send discord new profile', e)
|
console.error('Failed to send discord new profile', e)
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const nProfiles = await pg.one<number>(
|
const nProfiles = await pg.one<number>(
|
||||||
@@ -69,7 +72,7 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
|
|||||||
n % 50 === 0
|
n % 50 === 0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
console.log(nProfiles, isMilestone(nProfiles))
|
console.debug(nProfiles, isMilestone(nProfiles))
|
||||||
if (isMilestone(nProfiles)) {
|
if (isMilestone(nProfiles)) {
|
||||||
await sendDiscordMessage(
|
await sendDiscordMessage(
|
||||||
`We just reached **${nProfiles}** total profiles! 🎉`,
|
`We just reached **${nProfiles}** total profiles! 🎉`,
|
||||||
@@ -78,7 +81,7 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
|
|||||||
}
|
}
|
||||||
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('Failed to send discord user milestone', e)
|
console.error('Failed to send discord user milestone', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ 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 {RESERVED_PATHS} from 'common/envs/constants'
|
import {IS_LOCAL, RESERVED_PATHS} from 'common/envs/constants'
|
||||||
import {getUser, getUserByUsername, log} from 'shared/utils'
|
import {getUser, getUserByUsername, log} from 'shared/utils'
|
||||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
import {insert} from 'shared/supabase/utils'
|
import {insert} from 'shared/supabase/utils'
|
||||||
import {convertPrivateUser, convertUser} from 'common/supabase/users'
|
import {convertPrivateUser, convertUser} from 'common/supabase/users'
|
||||||
import {getBucket} from "shared/firebase-utils";
|
import {getBucket} from "shared/firebase-utils";
|
||||||
|
import {sendWelcomeEmail} from "email/functions/helpers";
|
||||||
|
import {setLastOnlineTimeUser} from "api/set-last-online-time";
|
||||||
|
|
||||||
export const createUser: APIHandler<'create-user'> = async (
|
export const createUser: APIHandler<'create-user'> = async (
|
||||||
props,
|
props,
|
||||||
@@ -126,7 +128,17 @@ export const createUser: APIHandler<'create-user'> = async (
|
|||||||
try {
|
try {
|
||||||
await track(auth.uid, 'create profile', {username: user.username})
|
await track(auth.uid, 'create profile', {username: user.username})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('Failed to track create profile', 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 {}
|
||||||
|
}
|
||||||
@@ -24,8 +24,11 @@ export const deleteMe: APIHandler<'me/delete'> = async (body, auth) => {
|
|||||||
// Remove user data from Supabase
|
// Remove user data from Supabase
|
||||||
const pg = createSupabaseDirectClient()
|
const pg = createSupabaseDirectClient()
|
||||||
await pg.none('DELETE FROM users WHERE id = $1', [userId])
|
await pg.none('DELETE FROM users WHERE id = $1', [userId])
|
||||||
await pg.none('DELETE FROM private_users WHERE id = $1', [userId])
|
// Should cascade delete in other tables
|
||||||
await pg.none('DELETE FROM profiles WHERE user_id = $1', [userId])
|
// await pg.none('DELETE FROM private_users WHERE id = $1', [userId])
|
||||||
|
// await pg.none('DELETE FROM profiles WHERE user_id = $1', [userId])
|
||||||
|
// await pg.none('DELETE FROM bookmarked_searches WHERE creator_id = $1', [userId])
|
||||||
|
// 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.)
|
// May need to also delete from other tables in the future (such as messages, compatibility responses, etc.)
|
||||||
|
|
||||||
// Delete user files from Firebase Storage
|
// Delete user files from Firebase Storage
|
||||||
@@ -35,7 +38,7 @@ export const deleteMe: APIHandler<'me/delete'> = async (body, auth) => {
|
|||||||
try {
|
try {
|
||||||
const auth = admin.auth()
|
const auth = admin.auth()
|
||||||
await auth.deleteUser(userId)
|
await auth.deleteUser(userId)
|
||||||
console.log(`Deleted user ${userId} from Firebase Auth and Supabase`)
|
console.debug(`Deleted user ${userId} from Firebase Auth and Supabase`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error deleting user from Firebase Auth:', e)
|
console.error('Error deleting user from Firebase Auth:', e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,30 +16,30 @@ export const getCompatibilityQuestions: APIHandler<
|
|||||||
> = async (_props, _auth) => {
|
> = async (_props, _auth) => {
|
||||||
const pg = createSupabaseDirectClient()
|
const pg = createSupabaseDirectClient()
|
||||||
|
|
||||||
const dbQuestions = 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
|
||||||
`,
|
`,
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
const questions = shuffle(dbQuestions)
|
// const questions = shuffle(dbQuestions)
|
||||||
|
|
||||||
// console.log(
|
// console.debug(
|
||||||
// 'got questions',
|
// 'got questions',
|
||||||
// questions.map((q) => q.question + ' ' + q.score)
|
// questions.map((q) => q.question + ' ' + q.score)
|
||||||
// )
|
// )
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -174,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
|
||||||
@@ -188,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,
|
||||||
@@ -211,7 +272,7 @@ 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})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ import { createSupabaseDirectClient } from 'shared/supabase/init'
|
|||||||
import {
|
import {
|
||||||
insertPrivateMessage,
|
insertPrivateMessage,
|
||||||
leaveChatContent,
|
leaveChatContent,
|
||||||
} from 'api/junk-drawer/private-messages'
|
} from 'api/helpers/private-messages'
|
||||||
|
|
||||||
export const leavePrivateUserMessageChannel: APIHandler<
|
export const leavePrivateUserMessageChannel: APIHandler<
|
||||||
'leave-private-user-message-channel'
|
'leave-private-user-message-channel'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||||
import { APIError, APIHandler } from './helpers/endpoint'
|
import { APIError, APIHandler } from './helpers/endpoint'
|
||||||
import { createLoveLikeNotification } from 'shared/create-love-notification'
|
import { createProfileLikeNotification } from 'shared/create-profile-notification'
|
||||||
import { getHasFreeLike } from './has-free-like'
|
import { getHasFreeLike } from './has-free-like'
|
||||||
import { log } from 'shared/utils'
|
import { log } from 'shared/utils'
|
||||||
import { tryCatch } from 'common/util/try-catch'
|
import { tryCatch } from 'common/util/try-catch'
|
||||||
@@ -15,7 +15,7 @@ export const likeProfile: APIHandler<'like-profile'> = async (props, auth) => {
|
|||||||
if (remove) {
|
if (remove) {
|
||||||
const { error } = await tryCatch(
|
const { error } = await tryCatch(
|
||||||
pg.none(
|
pg.none(
|
||||||
'delete from love_likes where creator_id = $1 and target_id = $2',
|
'delete from profile_likes where creator_id = $1 and target_id = $2',
|
||||||
[creatorId, targetUserId]
|
[creatorId, targetUserId]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -28,8 +28,8 @@ export const likeProfile: APIHandler<'like-profile'> = async (props, auth) => {
|
|||||||
|
|
||||||
// Check if like already exists
|
// Check if like already exists
|
||||||
const { data: existing } = await tryCatch(
|
const { data: existing } = await tryCatch(
|
||||||
pg.oneOrNone<Row<'love_likes'>>(
|
pg.oneOrNone<Row<'profile_likes'>>(
|
||||||
'select * from love_likes where creator_id = $1 and target_id = $2',
|
'select * from profile_likes where creator_id = $1 and target_id = $2',
|
||||||
[creatorId, targetUserId]
|
[creatorId, targetUserId]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -48,8 +48,8 @@ export const likeProfile: APIHandler<'like-profile'> = async (props, auth) => {
|
|||||||
|
|
||||||
// Insert the new like
|
// Insert the new like
|
||||||
const { data, error } = await tryCatch(
|
const { data, error } = await tryCatch(
|
||||||
pg.one<Row<'love_likes'>>(
|
pg.one<Row<'profile_likes'>>(
|
||||||
'insert into love_likes (creator_id, target_id) values ($1, $2) returning *',
|
'insert into profile_likes (creator_id, target_id) values ($1, $2) returning *',
|
||||||
[creatorId, targetUserId]
|
[creatorId, targetUserId]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -59,7 +59,7 @@ export const likeProfile: APIHandler<'like-profile'> = async (props, auth) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const continuation = async () => {
|
const continuation = async () => {
|
||||||
await createLoveLikeNotification(data)
|
await createProfileLikeNotification(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { APIError, APIHandler } from './helpers/endpoint'
|
import {APIError, APIHandler} from './helpers/endpoint'
|
||||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
import { tryCatch } from 'common/util/try-catch'
|
import {tryCatch} from 'common/util/try-catch'
|
||||||
import { insert } from 'shared/supabase/utils'
|
import {insert} from 'shared/supabase/utils'
|
||||||
|
import {sendDiscordMessage} from "common/discord/core";
|
||||||
|
import {Row} from "common/supabase/utils";
|
||||||
|
import {DOMAIN} from "common/envs/constants";
|
||||||
|
|
||||||
// abusable: people can report the wrong person, that didn't write the comment
|
// abusable: people can report the wrong person, that didn't write the comment
|
||||||
// but in practice we check it manually and nothing bad happens to them automatically
|
// but in practice we check it manually and nothing bad happens to them automatically
|
||||||
@@ -33,5 +36,38 @@ export const report: APIHandler<'report'> = async (body, auth) => {
|
|||||||
throw new APIError(500, 'Failed to create report: ' + result.error.message)
|
throw new APIError(500, 'Failed to create report: ' + result.error.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true }
|
const continuation = async () => {
|
||||||
|
try {
|
||||||
|
const {data: reporter, error} = await tryCatch(
|
||||||
|
pg.oneOrNone<Row<'users'>>('select * from users where id = $1', [auth.uid])
|
||||||
|
)
|
||||||
|
if (error) {
|
||||||
|
console.error('Failed to get user for report', error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const {data: reported, error: userError} = await tryCatch(
|
||||||
|
pg.oneOrNone<Row<'users'>>('select * from users where id = $1', [contentOwnerId])
|
||||||
|
)
|
||||||
|
if (userError) {
|
||||||
|
console.error('Failed to get reported user for report', userError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let message: string = `
|
||||||
|
🚨 **New Report** 🚨
|
||||||
|
**Type:** ${contentType}
|
||||||
|
**Content ID:** ${contentId}
|
||||||
|
**Reporter:** ${reporter?.name} ([@${reporter?.username}](https://www.${DOMAIN}/${reporter?.username}))
|
||||||
|
**Reported:** ${reported?.name} ([@${reported?.username}](https://www.${DOMAIN}/${reported?.username}))
|
||||||
|
`
|
||||||
|
await sendDiscordMessage(message, 'reports')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to send discord reports', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: {},
|
||||||
|
continue: continuation,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
backend/api/src/save-subscription.ts
Normal file
41
backend/api/src/save-subscription.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import {APIError, APIHandler} from './helpers/endpoint'
|
||||||
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
|
||||||
|
export const saveSubscription: APIHandler<'save-subscription'> = async (body, auth) => {
|
||||||
|
const {subscription} = body
|
||||||
|
|
||||||
|
if (!subscription?.endpoint || !subscription?.keys) {
|
||||||
|
throw new APIError(400, `Invalid subscription object`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = auth?.uid
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
|
// Check if a subscription already exists
|
||||||
|
const exists = await pg.oneOrNone(
|
||||||
|
'select id from push_subscriptions where endpoint = $1',
|
||||||
|
[subscription.endpoint]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
// Already exists, optionally update keys and userId
|
||||||
|
await pg.none(
|
||||||
|
'update push_subscriptions set keys = $1, user_id = $2 where id = $3',
|
||||||
|
[subscription.keys, userId, exists.id]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await pg.none(
|
||||||
|
`insert into push_subscriptions(endpoint, keys, user_id) values($1, $2, $3)
|
||||||
|
on conflict(endpoint) do update set keys = excluded.keys
|
||||||
|
`,
|
||||||
|
[subscription.endpoint, subscription.keys, userId]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {success: true};
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error saving subscription', err);
|
||||||
|
throw new APIError(500, `Failed to save subscription`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,5 +4,6 @@ import {geodbFetch} from "common/geodb";
|
|||||||
export const searchLocation: APIHandler<'search-location'> = async (body) => {
|
export const searchLocation: APIHandler<'search-location'> = async (body) => {
|
||||||
const {term, limit} = body
|
const {term, limit} = body
|
||||||
const endpoint = `/cities?namePrefix=${term}&limit=${limit ?? 10}&offset=0&sort=-population`
|
const endpoint = `/cities?namePrefix=${term}&limit=${limit ?? 10}&offset=0&sort=-population`
|
||||||
|
// const endpoint = `/countries?namePrefix=${term}&limit=${limit ?? 10}&offset=0`
|
||||||
return await geodbFetch(endpoint)
|
return await geodbFetch(endpoint)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,13 +2,12 @@ import {APIHandler} from './helpers/endpoint'
|
|||||||
import {geodbFetch} from "common/geodb";
|
import {geodbFetch} from "common/geodb";
|
||||||
|
|
||||||
const searchNearCityMain = async (cityId: string, radius: number) => {
|
const searchNearCityMain = async (cityId: string, radius: number) => {
|
||||||
// Limit to 10 cities for now for free plan, was 100 before (may need to buy plan)
|
const endpoint = `/cities/${cityId}/nearbyCities?radius=${radius}&offset=0&sort=-population&limit=100`
|
||||||
const endpoint = `/cities/${cityId}/nearbyCities?radius=${radius}&offset=0&sort=-population&limit=10`
|
|
||||||
return await geodbFetch(endpoint)
|
return await geodbFetch(endpoint)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const searchNearCity: APIHandler<'search-near-city'> = async (body) => {
|
export const searchNearCity: APIHandler<'search-near-city'> = async (body) => {
|
||||||
const { cityId, radius } = body
|
const {cityId, radius} = body
|
||||||
return await searchNearCityMain(cityId, radius)
|
return await searchNearCityMain(cityId, radius)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,15 +20,15 @@ export const searchUsers: APIHandler<'search-users'> = async (props, auth) => {
|
|||||||
const pg = createSupabaseDirectClient()
|
const pg = createSupabaseDirectClient()
|
||||||
|
|
||||||
const offset = page * limit
|
const offset = page * limit
|
||||||
const userId = auth?.uid
|
// const userId = auth?.uid
|
||||||
const searchFollowersSQL = getSearchUserSQL({ term, offset, limit, userId })
|
// const searchFollowersSQL = getSearchUserSQL({ term, offset, limit, userId })
|
||||||
const searchAllSQL = getSearchUserSQL({ term, offset, limit })
|
const searchAllSQL = getSearchUserSQL({ term, offset, limit })
|
||||||
const [followers, all] = await Promise.all([
|
const [all] = await Promise.all([
|
||||||
pg.map(searchFollowersSQL, null, convertUser),
|
// pg.map(searchFollowersSQL, null, convertUser),
|
||||||
pg.map(searchAllSQL, null, convertUser),
|
pg.map(searchAllSQL, null, convertUser),
|
||||||
])
|
])
|
||||||
|
|
||||||
return uniqBy([...followers, ...all], 'id')
|
return uniqBy([...all], 'id')
|
||||||
.map(toUserAPIResponse)
|
.map(toUserAPIResponse)
|
||||||
.slice(0, limit)
|
.slice(0, limit)
|
||||||
}
|
}
|
||||||
@@ -39,17 +39,18 @@ function getSearchUserSQL(props: {
|
|||||||
limit: number
|
limit: number
|
||||||
userId?: string // search only this user's followers
|
userId?: string // search only this user's followers
|
||||||
}) {
|
}) {
|
||||||
const { term, userId } = props
|
const { term } = props
|
||||||
|
|
||||||
return renderSql(
|
return renderSql(
|
||||||
userId
|
// userId
|
||||||
? [
|
// ? [
|
||||||
select('users.*'),
|
// select('users.*'),
|
||||||
from('users'),
|
// from('users'),
|
||||||
join('user_follows on user_follows.follow_id = users.id'),
|
// join('user_follows on user_follows.follow_id = users.id'),
|
||||||
where('user_follows.user_id = $1', [userId]),
|
// where('user_follows.user_id = $1', [userId]),
|
||||||
]
|
// ]
|
||||||
: [select('*'), from('users')],
|
// :
|
||||||
|
[select('*'), from('users')],
|
||||||
term
|
term
|
||||||
? [
|
? [
|
||||||
where(
|
where(
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {from, renderSql, select} from "shared/supabase/sql-builder";
|
|||||||
import {loadProfiles, profileQueryType} from "api/get-profiles";
|
import {loadProfiles, profileQueryType} from "api/get-profiles";
|
||||||
import {Row} from "common/supabase/utils";
|
import {Row} from "common/supabase/utils";
|
||||||
import {sendSearchAlertsEmail} from "email/functions/helpers";
|
import {sendSearchAlertsEmail} from "email/functions/helpers";
|
||||||
import {MatchesByUserType} from "common/love/bookmarked_searches";
|
import {MatchesByUserType} from "common/profiles/bookmarked_searches";
|
||||||
import {keyBy} from "lodash";
|
import {keyBy} from "lodash";
|
||||||
|
|
||||||
export function convertSearchRow(row: any): any {
|
export function convertSearchRow(row: any): any {
|
||||||
@@ -25,7 +25,7 @@ export const sendSearchNotifications = async () => {
|
|||||||
from('bookmarked_searches'),
|
from('bookmarked_searches'),
|
||||||
)
|
)
|
||||||
const searches = await pg.map(search_query, [], convertSearchRow) as Row<'bookmarked_searches'>[]
|
const searches = await pg.map(search_query, [], convertSearchRow) as Row<'bookmarked_searches'>[]
|
||||||
console.log(`Running ${searches.length} bookmarked searches`)
|
console.debug(`Running ${searches.length} bookmarked searches`)
|
||||||
|
|
||||||
const _users = await pg.map(
|
const _users = await pg.map(
|
||||||
renderSql(
|
renderSql(
|
||||||
@@ -36,7 +36,7 @@ export const sendSearchNotifications = async () => {
|
|||||||
convertSearchRow
|
convertSearchRow
|
||||||
) as Row<'users'>[]
|
) as Row<'users'>[]
|
||||||
const users = keyBy(_users, 'id')
|
const users = keyBy(_users, 'id')
|
||||||
console.log('users', users)
|
console.debug('users', users)
|
||||||
|
|
||||||
const _privateUsers = await pg.map(
|
const _privateUsers = await pg.map(
|
||||||
renderSql(
|
renderSql(
|
||||||
@@ -47,15 +47,21 @@ export const sendSearchNotifications = async () => {
|
|||||||
convertSearchRow
|
convertSearchRow
|
||||||
) as Row<'private_users'>[]
|
) as Row<'private_users'>[]
|
||||||
const privateUsers = keyBy(_privateUsers, 'id')
|
const privateUsers = keyBy(_privateUsers, 'id')
|
||||||
console.log('privateUsers', privateUsers)
|
console.debug('privateUsers', privateUsers)
|
||||||
|
|
||||||
const matches: MatchesByUserType = {}
|
const matches: MatchesByUserType = {}
|
||||||
|
|
||||||
for (const row of searches) {
|
for (const row of searches) {
|
||||||
if (typeof row.search_filters !== 'object') continue;
|
if (typeof row.search_filters !== 'object') continue;
|
||||||
const props = {...row.search_filters, skipId: row.creator_id, lastModificationWithin: '24 hours'}
|
const { orderBy, ...filters } = (row.search_filters ?? {}) as Record<string, any>
|
||||||
|
const props = {
|
||||||
|
...filters,
|
||||||
|
skipId: row.creator_id,
|
||||||
|
lastModificationWithin: '24 hours',
|
||||||
|
shortBio: true,
|
||||||
|
}
|
||||||
const profiles = await loadProfiles(props as profileQueryType)
|
const profiles = await loadProfiles(props as profileQueryType)
|
||||||
console.log(profiles.map((item: any) => item.name))
|
console.debug(profiles.map((item: any) => item.name))
|
||||||
if (!profiles.length) continue
|
if (!profiles.length) continue
|
||||||
if (!(row.creator_id in matches)) {
|
if (!(row.creator_id in matches)) {
|
||||||
if (!privateUsers[row.creator_id]) continue
|
if (!privateUsers[row.creator_id]) continue
|
||||||
@@ -74,7 +80,7 @@ export const sendSearchNotifications = async () => {
|
|||||||
})),
|
})),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
console.log('matches:', JSON.stringify(matches, null, 2))
|
console.debug('matches:', JSON.stringify(matches, null, 2))
|
||||||
await notifyBookmarkedSearch(matches)
|
await notifyBookmarkedSearch(matches)
|
||||||
|
|
||||||
return {status: 'success'}
|
return {status: 'success'}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import "tsconfig-paths/register";
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import {initAdmin} from 'shared/init-admin'
|
import {initAdmin} from 'shared/init-admin'
|
||||||
import {loadSecretsToEnv} from 'common/secrets'
|
import {loadSecretsToEnv} from 'common/secrets'
|
||||||
|
|||||||
34
backend/api/src/set-compatibility-answer.ts
Normal file
34
backend/api/src/set-compatibility-answer.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import {APIHandler} from './helpers/endpoint'
|
||||||
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
import {Row} from 'common/supabase/utils'
|
||||||
|
|
||||||
|
export const setCompatibilityAnswer: APIHandler<'set-compatibility-answer'> = async (
|
||||||
|
{questionId, multipleChoice, prefChoices, importance, explanation},
|
||||||
|
auth
|
||||||
|
) => {
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
|
|
||||||
|
const result = await pg.one<Row<'compatibility_answers'>>({
|
||||||
|
text: `
|
||||||
|
INSERT INTO compatibility_answers
|
||||||
|
(creator_id, question_id, multiple_choice, pref_choices, importance, explanation)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (question_id, creator_id)
|
||||||
|
DO UPDATE SET multiple_choice = EXCLUDED.multiple_choice,
|
||||||
|
pref_choices = EXCLUDED.pref_choices,
|
||||||
|
importance = EXCLUDED.importance,
|
||||||
|
explanation = EXCLUDED.explanation
|
||||||
|
RETURNING *
|
||||||
|
`,
|
||||||
|
values: [
|
||||||
|
auth.uid,
|
||||||
|
questionId,
|
||||||
|
multipleChoice,
|
||||||
|
prefChoices,
|
||||||
|
importance,
|
||||||
|
explanation ?? null,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
26
backend/api/src/set-last-online-time.ts
Normal file
26
backend/api/src/set-last-online-time.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import {APIHandler} from './helpers/endpoint'
|
||||||
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
|
||||||
|
export const setLastOnlineTime: APIHandler<'set-last-online-time'> = async (
|
||||||
|
_,
|
||||||
|
auth
|
||||||
|
) => {
|
||||||
|
if (!auth || !auth.uid) return
|
||||||
|
await setLastOnlineTimeUser(auth.uid)
|
||||||
|
// console.log('setLastOnline')
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const setLastOnlineTimeUser = async (userId: string) => {
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
|
await pg.none(`
|
||||||
|
INSERT INTO user_activity (user_id, last_online_time)
|
||||||
|
VALUES ($1, now())
|
||||||
|
ON CONFLICT (user_id)
|
||||||
|
DO UPDATE
|
||||||
|
SET last_online_time = EXCLUDED.last_online_time
|
||||||
|
WHERE user_activity.last_online_time < now() - interval '1 minute';
|
||||||
|
`,
|
||||||
|
[userId]
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||||
import { APIError, APIHandler } from './helpers/endpoint'
|
import { APIError, APIHandler } from './helpers/endpoint'
|
||||||
import { createLoveShipNotification } from 'shared/create-love-notification'
|
import { createProfileShipNotification } from 'shared/create-profile-notification'
|
||||||
import { log } from 'shared/utils'
|
import { log } from 'shared/utils'
|
||||||
import { tryCatch } from 'common/util/try-catch'
|
import { tryCatch } from 'common/util/try-catch'
|
||||||
import { insert } from 'shared/supabase/utils'
|
import { insert } from 'shared/supabase/utils'
|
||||||
@@ -14,7 +14,7 @@ export const shipProfiles: APIHandler<'ship-profiles'> = async (props, auth) =>
|
|||||||
// Check if ship already exists or with swapped target IDs
|
// Check if ship already exists or with swapped target IDs
|
||||||
const existing = await tryCatch(
|
const existing = await tryCatch(
|
||||||
pg.oneOrNone<{ ship_id: string }>(
|
pg.oneOrNone<{ ship_id: string }>(
|
||||||
`select ship_id from love_ships
|
`select ship_id from profile_ships
|
||||||
where creator_id = $1
|
where creator_id = $1
|
||||||
and (
|
and (
|
||||||
target1_id = $2 and target2_id = $3
|
target1_id = $2 and target2_id = $3
|
||||||
@@ -33,7 +33,7 @@ export const shipProfiles: APIHandler<'ship-profiles'> = async (props, auth) =>
|
|||||||
if (existing.data) {
|
if (existing.data) {
|
||||||
if (remove) {
|
if (remove) {
|
||||||
const { error } = await tryCatch(
|
const { error } = await tryCatch(
|
||||||
pg.none('delete from love_ships where ship_id = $1', [
|
pg.none('delete from profile_ships where ship_id = $1', [
|
||||||
existing.data.ship_id,
|
existing.data.ship_id,
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
@@ -48,7 +48,7 @@ export const shipProfiles: APIHandler<'ship-profiles'> = async (props, auth) =>
|
|||||||
|
|
||||||
// Insert the new ship
|
// Insert the new ship
|
||||||
const { data, error } = await tryCatch(
|
const { data, error } = await tryCatch(
|
||||||
insert(pg, 'love_ships', {
|
insert(pg, 'profile_ships', {
|
||||||
creator_id: creatorId,
|
creator_id: creatorId,
|
||||||
target1_id: targetUserId1,
|
target1_id: targetUserId1,
|
||||||
target2_id: targetUserId2,
|
target2_id: targetUserId2,
|
||||||
@@ -61,8 +61,8 @@ export const shipProfiles: APIHandler<'ship-profiles'> = async (props, auth) =>
|
|||||||
|
|
||||||
const continuation = async () => {
|
const continuation = async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
createLoveShipNotification(data, data.target1_id),
|
createProfileShipNotification(data, data.target1_id),
|
||||||
createLoveShipNotification(data, data.target2_id),
|
createProfileShipNotification(data, data.target2_id),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const starProfile: APIHandler<'star-profile'> = async (props, auth) => {
|
|||||||
if (remove) {
|
if (remove) {
|
||||||
const { error } = await tryCatch(
|
const { error } = await tryCatch(
|
||||||
pg.none(
|
pg.none(
|
||||||
'delete from love_stars where creator_id = $1 and target_id = $2',
|
'delete from profile_stars where creator_id = $1 and target_id = $2',
|
||||||
[creatorId, targetUserId]
|
[creatorId, targetUserId]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -27,8 +27,8 @@ export const starProfile: APIHandler<'star-profile'> = async (props, auth) => {
|
|||||||
|
|
||||||
// Check if star already exists
|
// Check if star already exists
|
||||||
const { data: existing } = await tryCatch(
|
const { data: existing } = await tryCatch(
|
||||||
pg.oneOrNone<Row<'love_stars'>>(
|
pg.oneOrNone<Row<'profile_stars'>>(
|
||||||
'select * from love_stars where creator_id = $1 and target_id = $2',
|
'select * from profile_stars where creator_id = $1 and target_id = $2',
|
||||||
[creatorId, targetUserId]
|
[creatorId, targetUserId]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -40,7 +40,7 @@ export const starProfile: APIHandler<'star-profile'> = async (props, auth) => {
|
|||||||
|
|
||||||
// Insert the new star
|
// Insert the new star
|
||||||
const { error } = await tryCatch(
|
const { error } = await tryCatch(
|
||||||
insert(pg, 'love_stars', { creator_id: creatorId, target_id: targetUserId })
|
insert(pg, 'profile_stars', { creator_id: creatorId, target_id: targetUserId })
|
||||||
)
|
)
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { APIError, APIHandler } from 'api/helpers/endpoint'
|
import { APIError, APIHandler } from 'api/helpers/endpoint'
|
||||||
import { removePinnedUrlFromPhotoUrls } from 'shared/love/parse-photos'
|
import { removePinnedUrlFromPhotoUrls } from 'shared/profiles/parse-photos'
|
||||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||||
import { updateUser } from 'shared/supabase/users'
|
import { updateUser } from 'shared/supabase/users'
|
||||||
import { log } from 'shared/utils'
|
import { log } from 'shared/utils'
|
||||||
@@ -24,8 +24,7 @@ export const updateProfile: APIHandler<'update-profile'> = async (
|
|||||||
throw new APIError(404, 'Profile not found')
|
throw new APIError(404, 'Profile not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
!parsedBody.last_online_time &&
|
log('Updating profile', { userId: auth.uid, parsedBody })
|
||||||
log('Updating profile', { userId: auth.uid, parsedBody })
|
|
||||||
|
|
||||||
await removePinnedUrlFromPhotoUrls(parsedBody)
|
await removePinnedUrlFromPhotoUrls(parsedBody)
|
||||||
if (parsedBody.avatar_url) {
|
if (parsedBody.avatar_url) {
|
||||||
|
|||||||
39
backend/api/src/vote.ts
Normal file
39
backend/api/src/vote.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||||
|
import { getUser } from 'shared/utils'
|
||||||
|
import { APIHandler, APIError } from './helpers/endpoint'
|
||||||
|
|
||||||
|
export const vote: APIHandler<'vote'> = async ({ voteId, choice, priority }, auth) => {
|
||||||
|
const user = await getUser(auth.uid)
|
||||||
|
if (!user) throw new APIError(401, 'Your account was not found')
|
||||||
|
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
|
|
||||||
|
// Map string choice to smallint (-1, 0, 1)
|
||||||
|
const choiceMap: Record<string, number> = {
|
||||||
|
'for': 1,
|
||||||
|
'abstain': 0,
|
||||||
|
'against': -1,
|
||||||
|
}
|
||||||
|
const choiceVal = choiceMap[choice]
|
||||||
|
if (choiceVal === undefined) {
|
||||||
|
throw new APIError(400, 'Invalid choice')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert the vote result to ensure one vote per user per vote
|
||||||
|
// Assuming table vote_results with unique (user_id, vote_id)
|
||||||
|
const query = `
|
||||||
|
insert into vote_results (user_id, vote_id, choice, priority)
|
||||||
|
values ($1, $2, $3, $4)
|
||||||
|
on conflict (user_id, vote_id)
|
||||||
|
do update set choice = excluded.choice,
|
||||||
|
priority = excluded.priority
|
||||||
|
returning *;
|
||||||
|
`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await pg.one(query, [user.id, voteId, choiceVal, priority])
|
||||||
|
return { data: result }
|
||||||
|
} catch (e) {
|
||||||
|
throw new APIError(500, 'Error recording vote', e as any)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,8 @@ set -e
|
|||||||
SERVICE_NAME="api"
|
SERVICE_NAME="api"
|
||||||
SERVICE_GROUP="${SERVICE_NAME}-group"
|
SERVICE_GROUP="${SERVICE_NAME}-group"
|
||||||
ZONE="us-west1-c"
|
ZONE="us-west1-c"
|
||||||
ENV=${1:-dev}
|
#ENV=${1:-dev}
|
||||||
|
ENV=prod
|
||||||
|
|
||||||
case $ENV in
|
case $ENV in
|
||||||
dev)
|
dev)
|
||||||
@@ -28,10 +29,19 @@ INSTANCE_ID=$(gcloud compute instances list \
|
|||||||
--format="value(name)" \
|
--format="value(name)" \
|
||||||
--limit=1)
|
--limit=1)
|
||||||
|
|
||||||
echo "Forwarding debugging port 9229 to ${INSTANCE_ID}. Open chrome://inspect in Chrome to connect."
|
#echo "Forwarding debugging port 9229 to ${INSTANCE_ID}. Open chrome://inspect in Chrome to connect."
|
||||||
echo gcloud compute ssh ${INSTANCE_ID} --project=${GCLOUD_PROJECT} --zone=${ZONE}
|
|
||||||
gcloud compute ssh ${INSTANCE_ID} \
|
if [ "$1" = "logs" ]; then
|
||||||
--project=${GCLOUD_PROJECT} \
|
CMD=(--command="sudo docker logs -f \$(sudo docker ps -alq)")
|
||||||
--zone=${ZONE} \
|
else
|
||||||
|
CMD=()
|
||||||
|
fi
|
||||||
|
|
||||||
|
gcloud compute ssh "${INSTANCE_ID}" \
|
||||||
|
--project="${GCLOUD_PROJECT}" \
|
||||||
|
--zone="${ZONE}" \
|
||||||
|
"${CMD[@]}"
|
||||||
|
|
||||||
# -- \
|
# -- \
|
||||||
# -NL 9229:localhost:9229
|
# -NL 9229:localhost:9229
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"tsBuildInfoFile": "lib/tsconfig.tsbuildinfo",
|
"tsBuildInfoFile": "lib/tsconfig.tsbuildinfo",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"target": "esnext",
|
"target": "esnext",
|
||||||
@@ -50,6 +51,7 @@
|
|||||||
"compileOnSave": true,
|
"compileOnSave": true,
|
||||||
"include": [
|
"include": [
|
||||||
"src/**/*.ts",
|
"src/**/*.ts",
|
||||||
"openapi.json"
|
"package.json",
|
||||||
|
"backend/api/package.json"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import {PrivateUser, User} from 'common/user'
|
import {PrivateUser, User} from 'common/user'
|
||||||
import {getNotificationDestinationsForUser} from 'common/user-notification-preferences'
|
import {getNotificationDestinationsForUser, UNSUBSCRIBE_URL} from 'common/user-notification-preferences'
|
||||||
import {sendEmail} from './send-email'
|
import {sendEmail} from './send-email'
|
||||||
import {NewMessageEmail} from '../new-message'
|
import {NewMessageEmail} from '../new-message'
|
||||||
import {NewEndorsementEmail} from '../new-endorsement'
|
import {NewEndorsementEmail} from '../new-endorsement'
|
||||||
import {Test} from '../test'
|
import {Test} from '../test'
|
||||||
import {getProfile} from 'shared/love/supabase'
|
import {getProfile} from 'shared/profiles/supabase'
|
||||||
import { render } from "@react-email/render"
|
import { render } from "@react-email/render"
|
||||||
import {MatchesType} from "common/love/bookmarked_searches";
|
import {MatchesType} from "common/profiles/bookmarked_searches";
|
||||||
import NewSearchAlertsEmail from "email/new-search_alerts";
|
import NewSearchAlertsEmail from "email/new-search_alerts";
|
||||||
|
import WelcomeEmail from "email/welcome";
|
||||||
|
|
||||||
const from = 'Compass <no-reply@compassmeet.com>'
|
const from = 'Compass <compass@compassmeet.com>'
|
||||||
|
|
||||||
// export const sendNewMatchEmail = async (
|
// export const sendNewMatchEmail = async (
|
||||||
// privateUser: PrivateUser,
|
// privateUser: PrivateUser,
|
||||||
@@ -75,6 +76,25 @@ export const sendNewMessageEmail = async (
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const sendWelcomeEmail = async (
|
||||||
|
toUser: User,
|
||||||
|
privateUser: PrivateUser,
|
||||||
|
) => {
|
||||||
|
if (!privateUser.email) return
|
||||||
|
return await sendEmail({
|
||||||
|
from,
|
||||||
|
subject: `Welcome to Compass!`,
|
||||||
|
to: privateUser.email,
|
||||||
|
html: await render(
|
||||||
|
<WelcomeEmail
|
||||||
|
toUser={toUser}
|
||||||
|
unsubscribeUrl={UNSUBSCRIBE_URL}
|
||||||
|
email={privateUser.email}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const sendSearchAlertsEmail = async (
|
export const sendSearchAlertsEmail = async (
|
||||||
toUser: User,
|
toUser: User,
|
||||||
privateUser: PrivateUser,
|
privateUser: PrivateUser,
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { ProfileRow } from 'common/love/profile'
|
import {ProfileRow} from 'common/profiles/profile'
|
||||||
import type { User } from 'common/user'
|
import type {User} from 'common/user'
|
||||||
|
|
||||||
// for email template testing
|
// for email template testing
|
||||||
|
|
||||||
export const sinclairUser: User = {
|
export const mockUser: User = {
|
||||||
createdTime: 0,
|
createdTime: 0,
|
||||||
bio: 'the futa in futarchy',
|
bio: 'the futa in futarchy',
|
||||||
website: 'sincl.ai',
|
website: 'sincl.ai',
|
||||||
@@ -17,7 +17,6 @@ export const sinclairUser: User = {
|
|||||||
id: '0k1suGSJKVUnHbCPEhHNpgZPkUP2',
|
id: '0k1suGSJKVUnHbCPEhHNpgZPkUP2',
|
||||||
username: 'Sinclair',
|
username: 'Sinclair',
|
||||||
name: 'Sinclair',
|
name: 'Sinclair',
|
||||||
// url: 'https://manifold.love/Sinclair',
|
|
||||||
// isAdmin: true,
|
// isAdmin: true,
|
||||||
// isTrustworthy: false,
|
// isTrustworthy: false,
|
||||||
link: {
|
link: {
|
||||||
@@ -31,14 +30,14 @@ export const sinclairProfile: ProfileRow = {
|
|||||||
id: 55,
|
id: 55,
|
||||||
user_id: '0k1suGSJKVUnHbCPEhHNpgZPkUP2',
|
user_id: '0k1suGSJKVUnHbCPEhHNpgZPkUP2',
|
||||||
created_time: '2023-10-27T00:41:59.851776+00:00',
|
created_time: '2023-10-27T00:41:59.851776+00:00',
|
||||||
last_online_time: '2024-05-17T02:11:48.83+00:00',
|
|
||||||
last_modification_time: '2024-05-17T02:11:48.83+00:00',
|
last_modification_time: '2024-05-17T02:11:48.83+00:00',
|
||||||
city: 'San Francisco',
|
city: 'San Francisco',
|
||||||
gender: 'trans-female',
|
gender: 'trans-female',
|
||||||
pref_gender: ['female', 'trans-female'],
|
pref_gender: ['female', 'trans-female'],
|
||||||
pref_age_min: 18,
|
pref_age_min: 18,
|
||||||
pref_age_max: 21,
|
pref_age_max: 21,
|
||||||
pref_relation_styles: ['poly', 'open', 'mono'],
|
pref_relation_styles: ['friendship'],
|
||||||
|
pref_romantic_styles: ['poly', 'open', 'mono'],
|
||||||
wants_kids_strength: 3,
|
wants_kids_strength: 3,
|
||||||
looking_for_matches: true,
|
looking_for_matches: true,
|
||||||
visibility: 'public',
|
visibility: 'public',
|
||||||
@@ -47,7 +46,7 @@ export const sinclairProfile: ProfileRow = {
|
|||||||
has_kids: 0,
|
has_kids: 0,
|
||||||
is_smoker: false,
|
is_smoker: false,
|
||||||
drinks_per_month: 0,
|
drinks_per_month: 0,
|
||||||
is_vegetarian_or_vegan: null,
|
diet: null,
|
||||||
political_beliefs: ['e/acc', 'libertarian'],
|
political_beliefs: ['e/acc', 'libertarian'],
|
||||||
religious_belief_strength: null,
|
religious_belief_strength: null,
|
||||||
religious_beliefs: null,
|
religious_beliefs: null,
|
||||||
@@ -78,6 +77,7 @@ export const sinclairProfile: ProfileRow = {
|
|||||||
city_longitude: -122.416389,
|
city_longitude: -122.416389,
|
||||||
geodb_city_id: '126964',
|
geodb_city_id: '126964',
|
||||||
referred_by_username: null,
|
referred_by_username: null,
|
||||||
|
bio_length: 1000,
|
||||||
bio: {
|
bio: {
|
||||||
type: 'doc',
|
type: 'doc',
|
||||||
content: [
|
content: [
|
||||||
@@ -101,6 +101,8 @@ export const sinclairProfile: ProfileRow = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
bio_text: 'the futa in futarchy',
|
||||||
|
bio_tsv: 'the futa in futarchy',
|
||||||
age: 25,
|
age: 25,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,14 +131,14 @@ export const jamesProfile: ProfileRow = {
|
|||||||
id: 2,
|
id: 2,
|
||||||
user_id: '5LZ4LgYuySdL1huCWe7bti02ghx2',
|
user_id: '5LZ4LgYuySdL1huCWe7bti02ghx2',
|
||||||
created_time: '2023-10-21T21:18:26.691211+00:00',
|
created_time: '2023-10-21T21:18:26.691211+00:00',
|
||||||
last_online_time: '2024-07-06T17:29:16.833+00:00',
|
|
||||||
last_modification_time: '2024-05-17T02:11:48.83+00:00',
|
last_modification_time: '2024-05-17T02:11:48.83+00:00',
|
||||||
city: 'San Francisco',
|
city: 'San Francisco',
|
||||||
gender: 'male',
|
gender: 'male',
|
||||||
pref_gender: ['female'],
|
pref_gender: ['female'],
|
||||||
pref_age_min: 22,
|
pref_age_min: 22,
|
||||||
pref_age_max: 32,
|
pref_age_max: 32,
|
||||||
pref_relation_styles: ['mono'],
|
pref_relation_styles: ['friendship'],
|
||||||
|
pref_romantic_styles: ['poly', 'open', 'mono'],
|
||||||
wants_kids_strength: 4,
|
wants_kids_strength: 4,
|
||||||
looking_for_matches: true,
|
looking_for_matches: true,
|
||||||
visibility: 'public',
|
visibility: 'public',
|
||||||
@@ -145,7 +147,7 @@ export const jamesProfile: ProfileRow = {
|
|||||||
has_kids: 0,
|
has_kids: 0,
|
||||||
is_smoker: false,
|
is_smoker: false,
|
||||||
drinks_per_month: 5,
|
drinks_per_month: 5,
|
||||||
is_vegetarian_or_vegan: null,
|
diet: null,
|
||||||
political_beliefs: ['libertarian'],
|
political_beliefs: ['libertarian'],
|
||||||
religious_belief_strength: null,
|
religious_belief_strength: null,
|
||||||
religious_beliefs: '',
|
religious_beliefs: '',
|
||||||
@@ -173,6 +175,7 @@ export const jamesProfile: ProfileRow = {
|
|||||||
city_longitude: -122.416389,
|
city_longitude: -122.416389,
|
||||||
geodb_city_id: '126964',
|
geodb_city_id: '126964',
|
||||||
referred_by_username: null,
|
referred_by_username: null,
|
||||||
|
bio_length: 1000,
|
||||||
bio: {
|
bio: {
|
||||||
type: 'doc',
|
type: 'doc',
|
||||||
content: [
|
content: [
|
||||||
@@ -202,5 +205,7 @@ export const jamesProfile: ProfileRow = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
bio_text: 'the futa in futarchy',
|
||||||
|
bio_tsv: 'the futa in futarchy',
|
||||||
age: 32,
|
age: 32,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,8 @@ import {
|
|||||||
type CreateEmailOptions,
|
type CreateEmailOptions,
|
||||||
} from 'resend'
|
} from 'resend'
|
||||||
import { log } from 'shared/utils'
|
import { log } from 'shared/utils'
|
||||||
|
import {sleep} from "common/util/time";
|
||||||
|
|
||||||
import pLimit from 'p-limit'
|
|
||||||
|
|
||||||
const limit = pLimit(1) // 1 concurrent per second
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* typically: { subject: string, to: string | string[] } & ({ text: string } | { react: ReactNode })
|
* typically: { subject: string, to: string | string[] } & ({ text: string } | { react: ReactNode })
|
||||||
@@ -17,18 +15,15 @@ export const sendEmail = async (
|
|||||||
options?: CreateEmailRequestOptions
|
options?: CreateEmailRequestOptions
|
||||||
) => {
|
) => {
|
||||||
const resend = getResend()
|
const resend = getResend()
|
||||||
console.log(resend, payload, options)
|
console.debug(resend, payload, options)
|
||||||
|
|
||||||
async function sendEmailThrottle(data: any, options: any) {
|
if (!resend) return null
|
||||||
if (!resend) return { data: null, error: 'No Resend client' }
|
|
||||||
return limit(() => resend.emails.send(data, options))
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data, error } = await sendEmailThrottle(
|
const { data, error } = await resend.emails.send(
|
||||||
{ replyTo: 'Compass <no-reply@compassmeet.com>', ...payload },
|
{ replyTo: 'Compass <hello@compassmeet.com>', ...payload },
|
||||||
options
|
options
|
||||||
)
|
)
|
||||||
console.log('resend.emails.send', data, error)
|
console.debug('resend.emails.send', data, error)
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
log.error(
|
log.error(
|
||||||
@@ -39,6 +34,9 @@ export const sendEmail = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
log(`Sent email to ${payload.to} with subject ${payload.subject}`)
|
log(`Sent email to ${payload.to} with subject ${payload.subject}`)
|
||||||
|
|
||||||
|
await sleep(1000) // to avoid rate limits (2 / second in resend free plan)
|
||||||
|
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,12 +45,12 @@ const getResend = () => {
|
|||||||
if (resend) return resend
|
if (resend) return resend
|
||||||
|
|
||||||
if (!process.env.RESEND_KEY) {
|
if (!process.env.RESEND_KEY) {
|
||||||
console.log('No RESEND_KEY, skipping email send')
|
console.debug('No RESEND_KEY, skipping email send')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiKey = process.env.RESEND_KEY as string
|
const apiKey = process.env.RESEND_KEY as string
|
||||||
// console.log(`RESEND_KEY: ${apiKey}`)
|
// console.debug(`RESEND_KEY: ${apiKey}`)
|
||||||
resend = new Resend(apiKey)
|
resend = new Resend(apiKey)
|
||||||
return resend
|
return resend
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ if (require.main === module) {
|
|||||||
const email = process.argv[2]
|
const email = process.argv[2]
|
||||||
if (!email) {
|
if (!email) {
|
||||||
console.error('Please provide an email address')
|
console.error('Please provide an email address')
|
||||||
console.log('Usage: ts-node send-test-email.ts your@email.com')
|
console.debug('Usage: ts-node send-test-email.ts your@email.com')
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
sendTestEmail(email)
|
sendTestEmail(email)
|
||||||
.then(() => console.log('Email sent successfully!'))
|
.then(() => console.debug('Email sent successfully!'))
|
||||||
.catch((error) => console.error('Failed to send email:', error))
|
.catch((error) => console.error('Failed to send email:', error))
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import {Body, Button, Column, Container, Head, Html, Preview, Row, Section, Text,} from '@react-email/components'
|
import {Body, Button, Column, Container, Head, Html, Preview, Row, Section, Text,} from '@react-email/components'
|
||||||
import {type User} from 'common/user'
|
import {type User} from 'common/user'
|
||||||
import {DOMAIN} from 'common/envs/constants'
|
import {DOMAIN} from 'common/envs/constants'
|
||||||
import {jamesUser, sinclairUser} from './functions/mock'
|
import {jamesUser, mockUser} from './functions/mock'
|
||||||
import {button, container, content, Footer, main, paragraph} from "email/utils";
|
import {button, container, content, Footer, main, paragraph} from "email/utils";
|
||||||
|
|
||||||
interface NewEndorsementEmailProps {
|
interface NewEndorsementEmailProps {
|
||||||
@@ -74,7 +74,7 @@ export const NewEndorsementEmail = ({
|
|||||||
|
|
||||||
NewEndorsementEmail.PreviewProps = {
|
NewEndorsementEmail.PreviewProps = {
|
||||||
fromUser: jamesUser,
|
fromUser: jamesUser,
|
||||||
onUser: sinclairUser,
|
onUser: mockUser,
|
||||||
endorsementText:
|
endorsementText:
|
||||||
"Sinclair is someone you want to have around because she injects creativity and humor into every conversation, and her laugh is infectious! Not to mention that she's a great employee, treats everyone with respect, and is even-tempered.",
|
"Sinclair is someone you want to have around because she injects creativity and humor into every conversation, and her laugh is infectious! Not to mention that she's a great employee, treats everyone with respect, and is even-tempered.",
|
||||||
unsubscribeUrl: 'https://compassmeet.com/unsubscribe',
|
unsubscribeUrl: 'https://compassmeet.com/unsubscribe',
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import {Body, Button, Container, Head, Html, Preview, Section, Text,} from '@react-email/components'
|
import {Body, Button, Container, Head, Html, Preview, Section, Text,} from '@react-email/components'
|
||||||
import {DOMAIN} from 'common/envs/constants'
|
import {DOMAIN} from 'common/envs/constants'
|
||||||
import {type ProfileRow} from 'common/love/profile'
|
import {type ProfileRow} from 'common/profiles/profile'
|
||||||
import {type User} from 'common/user'
|
import {type User} from 'common/user'
|
||||||
import {jamesProfile, jamesUser, sinclairUser} from './functions/mock'
|
import {jamesProfile, jamesUser, mockUser} from './functions/mock'
|
||||||
import {Footer} from "email/utils";
|
import {Footer} from "email/utils";
|
||||||
|
|
||||||
interface NewMatchEmailProps {
|
interface NewMatchEmailProps {
|
||||||
@@ -21,7 +21,7 @@ export const NewMatchEmail = ({
|
|||||||
email
|
email
|
||||||
}: NewMatchEmailProps) => {
|
}: NewMatchEmailProps) => {
|
||||||
const name = onUser.name.split(' ')[0]
|
const name = onUser.name.split(' ')[0]
|
||||||
// const userImgSrc = getLoveOgImageUrl(matchedWithUser, matchedProfile)
|
// const userImgSrc = getOgImageUrl(matchedWithUser, matchedProfile)
|
||||||
const userUrl = `https://${DOMAIN}/${matchedWithUser.username}`
|
const userUrl = `https://${DOMAIN}/${matchedWithUser.username}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -70,7 +70,7 @@ export const NewMatchEmail = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
NewMatchEmail.PreviewProps = {
|
NewMatchEmail.PreviewProps = {
|
||||||
onUser: sinclairUser,
|
onUser: mockUser,
|
||||||
matchedWithUser: jamesUser,
|
matchedWithUser: jamesUser,
|
||||||
matchedProfile: jamesProfile,
|
matchedProfile: jamesProfile,
|
||||||
email: 'someone@gmail.com',
|
email: 'someone@gmail.com',
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {Body, Button, Container, Head, Html, Preview, Section, Text,} from '@react-email/components'
|
import {Body, Button, Container, Head, Html, Preview, Section, Text,} from '@react-email/components'
|
||||||
import {type User} from 'common/user'
|
import {type User} from 'common/user'
|
||||||
import {type ProfileRow} from 'common/love/profile'
|
import {type ProfileRow} from 'common/profiles/profile'
|
||||||
import {jamesProfile, jamesUser, sinclairUser,} from './functions/mock'
|
import {jamesProfile, jamesUser, mockUser,} from './functions/mock'
|
||||||
import {DOMAIN} from 'common/envs/constants'
|
import {DOMAIN} from 'common/envs/constants'
|
||||||
import {button, container, content, Footer, imageContainer, main, paragraph} from "email/utils";
|
import {button, container, content, Footer, imageContainer, main, paragraph} from "email/utils";
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ export const NewMessageEmail = ({
|
|||||||
const name = toUser.name.split(' ')[0]
|
const name = toUser.name.split(' ')[0]
|
||||||
const creatorName = fromUser.name
|
const creatorName = fromUser.name
|
||||||
const messagesUrl = `https://${DOMAIN}/messages/${channelId}`
|
const messagesUrl = `https://${DOMAIN}/messages/${channelId}`
|
||||||
// const userImgSrc = getLoveOgImageUrl(fromUser, fromUserProfile)
|
// const userImgSrc = getOgImageUrl(fromUser, fromUserProfile)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
@@ -74,7 +74,7 @@ export const NewMessageEmail = ({
|
|||||||
NewMessageEmail.PreviewProps = {
|
NewMessageEmail.PreviewProps = {
|
||||||
fromUser: jamesUser,
|
fromUser: jamesUser,
|
||||||
fromUserProfile: jamesProfile,
|
fromUserProfile: jamesProfile,
|
||||||
toUser: sinclairUser,
|
toUser: mockUser,
|
||||||
channelId: 1,
|
channelId: 1,
|
||||||
email: 'someone@gmail.com',
|
email: 'someone@gmail.com',
|
||||||
unsubscribeUrl: 'https://compassmeet.com/unsubscribe',
|
unsubscribeUrl: 'https://compassmeet.com/unsubscribe',
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import {Body, Container, Head, Html, Link, Preview, Section, Text,} from '@react-email/components'
|
import {Body, Container, Head, Html, Link, Preview, Section, Text,} from '@react-email/components'
|
||||||
import {type User} from 'common/user'
|
import {type User} from 'common/user'
|
||||||
import {sinclairUser,} from './functions/mock'
|
import {mockUser,} from './functions/mock'
|
||||||
import {DOMAIN} from 'common/envs/constants'
|
import {DOMAIN} from 'common/envs/constants'
|
||||||
import {container, content, Footer, main, paragraph} from "email/utils";
|
import {container, content, Footer, main, paragraph} from "email/utils";
|
||||||
import {MatchesType} from "common/love/bookmarked_searches";
|
import {MatchesType} from "common/profiles/bookmarked_searches";
|
||||||
import {formatFilters, locationType} from "common/searches"
|
import {formatFilters, locationType} from "common/searches"
|
||||||
import {FilterFields} from "common/filters";
|
import {FilterFields} from "common/filters";
|
||||||
|
|
||||||
@@ -140,7 +140,7 @@ const matchSamples = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
NewSearchAlertsEmail.PreviewProps = {
|
NewSearchAlertsEmail.PreviewProps = {
|
||||||
toUser: sinclairUser,
|
toUser: mockUser,
|
||||||
email: 'someone@gmail.com',
|
email: 'someone@gmail.com',
|
||||||
unsubscribeUrl: 'https://compassmeet.com/unsubscribe',
|
unsubscribeUrl: 'https://compassmeet.com/unsubscribe',
|
||||||
matches: matchSamples,
|
matches: matchSamples,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {Column, Img, Link, Row, Section, Text} from "@react-email/components";
|
import {Column, Img, Link, Row, Section, Text} from "@react-email/components";
|
||||||
import {discordLink, githubRepo, patreonLink, paypalLink} from "common/constants";
|
|
||||||
import {DOMAIN} from "common/envs/constants";
|
import {DOMAIN} from "common/envs/constants";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -15,40 +14,40 @@ export const Footer = ({
|
|||||||
<hr style={{border: 'none', borderTop: '1px solid #e0e0e0', margin: '10px 0'}}/>
|
<hr style={{border: 'none', borderTop: '1px solid #e0e0e0', margin: '10px 0'}}/>
|
||||||
<Row>
|
<Row>
|
||||||
<Column align="center">
|
<Column align="center">
|
||||||
<Link href={githubRepo} target="_blank">
|
<Link href={`https://${DOMAIN}/github`} target="_blank">
|
||||||
<Img
|
<Img
|
||||||
src={`https://${DOMAIN}/images/github-logo.png`}
|
src={`https://${DOMAIN}/images/github-logo.png`}
|
||||||
width="24"
|
width="24"
|
||||||
height="24"
|
height="24"
|
||||||
alt="GitHub"
|
alt="GitHub"
|
||||||
style={{ display: "inline-block", margin: "0 4px" }}
|
style={{display: "inline-block", margin: "0 4px"}}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href={discordLink} target="_blank">
|
<Link href={`https://${DOMAIN}/discord`} target="_blank">
|
||||||
<Img
|
<Img
|
||||||
src={`https://${DOMAIN}/images/discord-logo.png`}
|
src={`https://${DOMAIN}/images/discord-logo.png`}
|
||||||
width="24"
|
width="24"
|
||||||
height="24"
|
height="24"
|
||||||
alt="Discord"
|
alt="Discord"
|
||||||
style={{ display: "inline-block", margin: "0 4px" }}
|
style={{display: "inline-block", margin: "0 4px"}}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href={patreonLink} target="_blank">
|
<Link href={`https://${DOMAIN}/patreon`} target="_blank">
|
||||||
<Img
|
<Img
|
||||||
src={`https://${DOMAIN}/images/patreon-logo.png`}
|
src={`https://${DOMAIN}/images/patreon-logo.png`}
|
||||||
width="24"
|
width="24"
|
||||||
height="24"
|
height="24"
|
||||||
alt="Patreon"
|
alt="Patreon"
|
||||||
style={{ display: "inline-block", margin: "0 4px" }}
|
style={{display: "inline-block", margin: "0 4px"}}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href={paypalLink} target="_blank">
|
<Link href={`https://${DOMAIN}/paypal`} target="_blank">
|
||||||
<Img
|
<Img
|
||||||
src={`https://${DOMAIN}/images/paypal-logo.png`}
|
src={`https://${DOMAIN}/images/paypal-logo.png`}
|
||||||
width="24"
|
width="24"
|
||||||
height="24"
|
height="24"
|
||||||
alt="PayPal"
|
alt="PayPal"
|
||||||
style={{ display: "inline-block", margin: "0 4px" }}
|
style={{display: "inline-block", margin: "0 4px"}}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
</Column>
|
</Column>
|
||||||
|
|||||||
82
backend/email/emails/welcome.tsx
Normal file
82
backend/email/emails/welcome.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import {Body, Button, Container, Head, Html, Preview, Section, Text,} from '@react-email/components'
|
||||||
|
import {type User} from 'common/user'
|
||||||
|
import {mockUser,} from './functions/mock'
|
||||||
|
import {button, container, content, Footer, main, paragraph} from "email/utils";
|
||||||
|
|
||||||
|
function randomHex(length: number) {
|
||||||
|
const bytes = new Uint8Array(Math.ceil(length / 2));
|
||||||
|
crypto.getRandomValues(bytes);
|
||||||
|
return Array.from(bytes, b => b.toString(16).padStart(2, "0"))
|
||||||
|
.join("")
|
||||||
|
.slice(0, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WelcomeEmailProps {
|
||||||
|
toUser: User
|
||||||
|
unsubscribeUrl: string
|
||||||
|
email?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WelcomeEmail = ({
|
||||||
|
toUser,
|
||||||
|
unsubscribeUrl,
|
||||||
|
email,
|
||||||
|
}: WelcomeEmailProps) => {
|
||||||
|
const name = toUser.name.split(' ')[0]
|
||||||
|
const confirmUrl = `https://compassmeet.com/confirm-email/${randomHex(16)}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Html>
|
||||||
|
<Head/>
|
||||||
|
<Preview>Welcome to Compass — Please confirm your email</Preview>
|
||||||
|
<Body style={main}>
|
||||||
|
<Container style={container}>
|
||||||
|
<Section style={content}>
|
||||||
|
<Text style={paragraph}>Welcome to Compass, {name}!</Text>
|
||||||
|
|
||||||
|
<Text style={paragraph}>
|
||||||
|
Compass is a free, community-owned platform built to help people form
|
||||||
|
deep, meaningful connections — platonic, romantic, or collaborative.
|
||||||
|
There are no ads, no hidden algorithms, and no subscriptions — just a
|
||||||
|
transparent, open-source space shaped by people like you.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text style={paragraph}>
|
||||||
|
To finish creating your account and start exploring Compass, please
|
||||||
|
confirm your email below:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
style={button}
|
||||||
|
href={confirmUrl}
|
||||||
|
>
|
||||||
|
Confirm My Email
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Text style={{marginTop: "40px", fontSize: "10px", color: "#555"}}>
|
||||||
|
Or copy and paste this link into your browser: <br/>
|
||||||
|
<a href={confirmUrl}>{confirmUrl}</a>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Text style={{marginTop: "40px", fontSize: "12px", color: "#555"}}>
|
||||||
|
Your presence and participation are what make Compass possible. Thank you
|
||||||
|
for helping us build an internet space that prioritizes depth, trust, and
|
||||||
|
community over monetization.
|
||||||
|
</Text>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Footer unsubscribeUrl={unsubscribeUrl} email={email ?? name}/>
|
||||||
|
</Container>
|
||||||
|
</Body>
|
||||||
|
</Html>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
WelcomeEmail.PreviewProps = {
|
||||||
|
toUser: mockUser,
|
||||||
|
email: 'someone@gmail.com',
|
||||||
|
unsubscribeUrl: 'https://compassmeet.com/unsubscribe',
|
||||||
|
} as WelcomeEmailProps
|
||||||
|
|
||||||
|
|
||||||
|
export default WelcomeEmail
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
"rules": "storage.rules"
|
"rules": "storage.rules"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"bucket": "compass-130ba-private.firebasestorage.app",
|
"bucket": "compass-130ba-private",
|
||||||
"rules": "private-storage.rules"
|
"rules": "private-storage.rules"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -7,4 +7,4 @@ service firebase.storage {
|
|||||||
allow write: if request.auth.uid == userId && request.resource.size <= 20 * 1024 * 1024; // 20MB
|
allow write: if request.auth.uid == userId && request.resource.size <= 20 * 1024 * 1024; // 20MB
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,7 @@ service firebase.storage {
|
|||||||
match /b/{bucket}/o {
|
match /b/{bucket}/o {
|
||||||
match /{allPaths=**} {
|
match /{allPaths=**} {
|
||||||
allow read;
|
allow read;
|
||||||
// Don't require auth, as dream uploads can be done by anyone
|
allow write: if request.auth != null && request.resource.size <= 10 * 1024 * 1024;
|
||||||
allow write: if request.resource.size <= 10 * 1024 * 1024; // 10MB
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ runScript(async ({ pg }) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log('updates', updates.slice(0, 10))
|
// console.debug('updates', updates.slice(0, 10))
|
||||||
// return
|
// return
|
||||||
|
|
||||||
let count = 0
|
let count = 0
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ runScript(async ({ pg }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const getNodes = async (pg: SupabaseDirectClient, nodeName: string) => {
|
const getNodes = async (pg: SupabaseDirectClient, nodeName: string) => {
|
||||||
console.log(`\nSearching comments for ${nodeName}...`)
|
console.debug(`\nSearching comments for ${nodeName}...`)
|
||||||
const commentQuery = renderSql(
|
const commentQuery = renderSql(
|
||||||
select('id, user_id, on_user_id, content'),
|
select('id, user_id, on_user_id, content'),
|
||||||
from('profile_comments'),
|
from('profile_comments'),
|
||||||
@@ -32,15 +32,15 @@ const getNodes = async (pg: SupabaseDirectClient, nodeName: string) => {
|
|||||||
)
|
)
|
||||||
const comments = await pg.manyOrNone(commentQuery)
|
const comments = await pg.manyOrNone(commentQuery)
|
||||||
|
|
||||||
console.log(`Found ${comments.length} comments:`)
|
console.debug(`Found ${comments.length} comments:`)
|
||||||
comments.forEach((comment) => {
|
comments.forEach((comment) => {
|
||||||
console.log('\nComment ID:', comment.id)
|
console.debug('\nComment ID:', comment.id)
|
||||||
console.log('From user:', comment.user_id)
|
console.debug('From user:', comment.user_id)
|
||||||
console.log('On user:', comment.on_user_id)
|
console.debug('On user:', comment.on_user_id)
|
||||||
console.log('Content:', JSON.stringify(comment.content))
|
console.debug('Content:', JSON.stringify(comment.content))
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log(`\nSearching private messages for ${nodeName}...`)
|
console.debug(`\nSearching private messages for ${nodeName}...`)
|
||||||
const messageQuery = renderSql(
|
const messageQuery = renderSql(
|
||||||
select('id, user_id, channel_id, content'),
|
select('id, user_id, channel_id, content'),
|
||||||
from('private_user_messages'),
|
from('private_user_messages'),
|
||||||
@@ -48,15 +48,15 @@ const getNodes = async (pg: SupabaseDirectClient, nodeName: string) => {
|
|||||||
)
|
)
|
||||||
const messages = await pg.manyOrNone(messageQuery)
|
const messages = await pg.manyOrNone(messageQuery)
|
||||||
|
|
||||||
console.log(`Found ${messages.length} private messages:`)
|
console.debug(`Found ${messages.length} private messages:`)
|
||||||
messages.forEach((msg) => {
|
messages.forEach((msg) => {
|
||||||
console.log('\nMessage ID:', msg.id)
|
console.debug('\nMessage ID:', msg.id)
|
||||||
console.log('From user:', msg.user_id)
|
console.debug('From user:', msg.user_id)
|
||||||
console.log('Channel:', msg.channel_id)
|
console.debug('Channel:', msg.channel_id)
|
||||||
console.log('Content:', JSON.stringify(msg.content))
|
console.debug('Content:', JSON.stringify(msg.content))
|
||||||
})
|
})
|
||||||
|
|
||||||
console.log(`\nSearching profiles for ${nodeName}...`)
|
console.debug(`\nSearching profiles for ${nodeName}...`)
|
||||||
const users = renderSql(
|
const users = renderSql(
|
||||||
select('user_id, bio'),
|
select('user_id, bio'),
|
||||||
from('profiles'),
|
from('profiles'),
|
||||||
@@ -64,9 +64,9 @@ const getNodes = async (pg: SupabaseDirectClient, nodeName: string) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
const usersWithMentions = await pg.manyOrNone(users)
|
const usersWithMentions = await pg.manyOrNone(users)
|
||||||
console.log(`Found ${usersWithMentions.length} users:`)
|
console.debug(`Found ${usersWithMentions.length} users:`)
|
||||||
usersWithMentions.forEach((user) => {
|
usersWithMentions.forEach((user) => {
|
||||||
console.log('\nUser ID:', user.user_id)
|
console.debug('\nUser ID:', user.user_id)
|
||||||
console.log('Bio:', JSON.stringify(user.bio))
|
console.debug('Bio:', JSON.stringify(user.bio))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,6 @@ select
|
|||||||
from
|
from
|
||||||
temp_users;
|
temp_users;
|
||||||
|
|
||||||
-- Rename temp_love_messages
|
|
||||||
-- alter table temp_love_messages
|
|
||||||
-- rename to private_user_messages;
|
|
||||||
|
|
||||||
-- alter table private_user_messages
|
-- alter table private_user_messages
|
||||||
-- alter column channel_id set not null,
|
-- alter column channel_id set not null,
|
||||||
-- alter column content set not null,
|
-- alter column content set not null,
|
||||||
@@ -12,7 +12,7 @@ DB_USER="postgres"
|
|||||||
PORT="5432"
|
PORT="5432"
|
||||||
|
|
||||||
psql -U $DB_USER -d postgres -h $DB_NAME -p $PORT -w \
|
psql -U $DB_USER -d postgres -h $DB_NAME -p $PORT -w \
|
||||||
-f ./love-stars-dump.sql \
|
-f ./....sql \
|
||||||
|
|
||||||
|
|
||||||
# psql -U $DB_USER -d postgres -h $DB_NAME -p $PORT -w \
|
# psql -U $DB_USER -d postgres -h $DB_NAME -p $PORT -w \
|
||||||
@@ -25,8 +25,5 @@ psql -U $DB_USER -d postgres -h $DB_NAME -p $PORT -w \
|
|||||||
# -f ../supabase/private_users.sql \
|
# -f ../supabase/private_users.sql \
|
||||||
# -f ../supabase/users.sql
|
# -f ../supabase/users.sql
|
||||||
|
|
||||||
# psql -U $DB_USER -d postgres -h $DB_NAME -p $PORT -w \
|
|
||||||
# -f './import-love-finalize.sql'
|
|
||||||
|
|
||||||
echo "Done"
|
echo "Done"
|
||||||
)
|
)
|
||||||
@@ -178,7 +178,7 @@ async function getTableInfo(pg: SupabaseDirectClient, tableName: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getFunctions(pg: SupabaseDirectClient) {
|
async function getFunctions(pg: SupabaseDirectClient) {
|
||||||
console.log('Getting functions')
|
console.debug('Getting functions')
|
||||||
const rows = await pg.manyOrNone<{
|
const rows = await pg.manyOrNone<{
|
||||||
function_name: string
|
function_name: string
|
||||||
definition: string
|
definition: string
|
||||||
@@ -196,7 +196,7 @@ async function getFunctions(pg: SupabaseDirectClient) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getViews(pg: SupabaseDirectClient) {
|
async function getViews(pg: SupabaseDirectClient) {
|
||||||
console.log('Getting views')
|
console.debug('Getting views')
|
||||||
return pg.manyOrNone<{ view_name: string; definition: string }>(
|
return pg.manyOrNone<{ view_name: string; definition: string }>(
|
||||||
`SELECT
|
`SELECT
|
||||||
table_name AS view_name,
|
table_name AS view_name,
|
||||||
@@ -214,7 +214,7 @@ async function generateSQLFiles(pg: SupabaseDirectClient) {
|
|||||||
(row) => row.tablename as string
|
(row) => row.tablename as string
|
||||||
)
|
)
|
||||||
|
|
||||||
console.log(`Getting info for ${tables.length} tables`)
|
console.debug(`Getting info for ${tables.length} tables`)
|
||||||
const tableInfos = await Promise.all(
|
const tableInfos = await Promise.all(
|
||||||
tables.map((table) => getTableInfo(pg, table))
|
tables.map((table) => getTableInfo(pg, table))
|
||||||
)
|
)
|
||||||
@@ -331,7 +331,7 @@ async function generateSQLFiles(pg: SupabaseDirectClient) {
|
|||||||
await fs.writeFile(`${outputDir}/${tableInfo.tableName}.sql`, content)
|
await fs.writeFile(`${outputDir}/${tableInfo.tableName}.sql`, content)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Writing remaining functions to functions.sql')
|
console.debug('Writing remaining functions to functions.sql')
|
||||||
let functionsContent = `-- This file is autogenerated from regen-schema.ts\n\n`
|
let functionsContent = `-- This file is autogenerated from regen-schema.ts\n\n`
|
||||||
|
|
||||||
for (const func of functions) {
|
for (const func of functions) {
|
||||||
@@ -340,7 +340,7 @@ async function generateSQLFiles(pg: SupabaseDirectClient) {
|
|||||||
|
|
||||||
await fs.writeFile(`${outputDir}/functions.sql`, functionsContent)
|
await fs.writeFile(`${outputDir}/functions.sql`, functionsContent)
|
||||||
|
|
||||||
console.log('Writing views to views.sql')
|
console.debug('Writing views to views.sql')
|
||||||
let viewsContent = `-- This file is autogenerated from regen-schema.ts\n\n`
|
let viewsContent = `-- This file is autogenerated from regen-schema.ts\n\n`
|
||||||
|
|
||||||
for (const view of views) {
|
for (const view of views) {
|
||||||
@@ -350,7 +350,7 @@ async function generateSQLFiles(pg: SupabaseDirectClient) {
|
|||||||
|
|
||||||
await fs.writeFile(`${outputDir}/views.sql`, viewsContent)
|
await fs.writeFile(`${outputDir}/views.sql`, viewsContent)
|
||||||
|
|
||||||
console.log('Prettifying SQL files...')
|
console.debug('Prettifying SQL files...')
|
||||||
execSync(
|
execSync(
|
||||||
`prettier --write ${outputDir}/*.sql --ignore-path ../supabase/.gitignore`
|
`prettier --write ${outputDir}/*.sql --ignore-path ../supabase/.gitignore`
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const removeNodesOfType = (
|
|||||||
runScript(async ({ pg }) => {
|
runScript(async ({ pg }) => {
|
||||||
const nodeType = 'linkPreview'
|
const nodeType = 'linkPreview'
|
||||||
|
|
||||||
console.log('\nSearching comments for linkPreviews...')
|
console.debug('\nSearching comments for linkPreviews...')
|
||||||
const commentQuery = renderSql(
|
const commentQuery = renderSql(
|
||||||
select('id, content'),
|
select('id, content'),
|
||||||
from('profile_comments'),
|
from('profile_comments'),
|
||||||
@@ -38,21 +38,21 @@ runScript(async ({ pg }) => {
|
|||||||
)
|
)
|
||||||
const comments = await pg.manyOrNone(commentQuery)
|
const comments = await pg.manyOrNone(commentQuery)
|
||||||
|
|
||||||
console.log(`Found ${comments.length} comments with linkPreviews`)
|
console.debug(`Found ${comments.length} comments with linkPreviews`)
|
||||||
|
|
||||||
for (const comment of comments) {
|
for (const comment of comments) {
|
||||||
const newContent = removeNodesOfType(comment.content, nodeType)
|
const newContent = removeNodesOfType(comment.content, nodeType)
|
||||||
console.log('before', comment.content)
|
console.debug('before', comment.content)
|
||||||
console.log('after', newContent)
|
console.debug('after', newContent)
|
||||||
|
|
||||||
await pg.none('update profile_comments set content = $1 where id = $2', [
|
await pg.none('update profile_comments set content = $1 where id = $2', [
|
||||||
newContent,
|
newContent,
|
||||||
comment.id,
|
comment.id,
|
||||||
])
|
])
|
||||||
console.log('Updated comment:', comment.id)
|
console.debug('Updated comment:', comment.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('\nSearching private messages for linkPreviews...')
|
console.debug('\nSearching private messages for linkPreviews...')
|
||||||
const messageQuery = renderSql(
|
const messageQuery = renderSql(
|
||||||
select('id, content'),
|
select('id, content'),
|
||||||
from('private_user_messages'),
|
from('private_user_messages'),
|
||||||
@@ -60,17 +60,17 @@ runScript(async ({ pg }) => {
|
|||||||
)
|
)
|
||||||
const messages = await pg.manyOrNone(messageQuery)
|
const messages = await pg.manyOrNone(messageQuery)
|
||||||
|
|
||||||
console.log(`Found ${messages.length} messages with linkPreviews`)
|
console.debug(`Found ${messages.length} messages with linkPreviews`)
|
||||||
|
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const newContent = removeNodesOfType(msg.content, nodeType)
|
const newContent = removeNodesOfType(msg.content, nodeType)
|
||||||
console.log('before', JSON.stringify(msg.content, null, 2))
|
console.debug('before', JSON.stringify(msg.content, null, 2))
|
||||||
console.log('after', JSON.stringify(newContent, null, 2))
|
console.debug('after', JSON.stringify(newContent, null, 2))
|
||||||
|
|
||||||
await pg.none(
|
await pg.none(
|
||||||
'update private_user_messages set content = $1 where id = $2',
|
'update private_user_messages set content = $1 where id = $2',
|
||||||
[newContent, msg.id]
|
[newContent, msg.id]
|
||||||
)
|
)
|
||||||
console.log('Updated message:', msg.id)
|
console.debug('Updated message:', msg.id)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
import {initAdmin} from 'shared/init-admin'
|
import {initAdmin} from 'shared/init-admin'
|
||||||
import {loadSecretsToEnv} from 'common/secrets'
|
|
||||||
import {createSupabaseDirectClient, type SupabaseDirectClient,} from 'shared/supabase/init'
|
import {createSupabaseDirectClient, type SupabaseDirectClient,} from 'shared/supabase/init'
|
||||||
import {getServiceAccountCredentials} from "shared/firebase-utils";
|
import {refreshConfig} from "common/envs/prod";
|
||||||
|
|
||||||
initAdmin()
|
|
||||||
|
|
||||||
export const runScript = async (
|
export const runScript = async (
|
||||||
main: (services: { pg: SupabaseDirectClient }) => Promise<any> | any
|
main: (services: { pg: SupabaseDirectClient }) => Promise<any> | any
|
||||||
) => {
|
) => {
|
||||||
const credentials = getServiceAccountCredentials()
|
initAdmin()
|
||||||
|
await initEnvVariables()
|
||||||
await loadSecretsToEnv(credentials)
|
console.debug('Environment variables in runScript:')
|
||||||
|
for (const k of Object.keys(process.env)) console.debug(`${k}=${process.env[k]}`)
|
||||||
|
|
||||||
|
console.debug('runScript: creating pg client...')
|
||||||
const pg = createSupabaseDirectClient()
|
const pg = createSupabaseDirectClient()
|
||||||
|
console.debug('runScript: running main...')
|
||||||
await main({pg})
|
await main({pg})
|
||||||
|
}
|
||||||
process.exit()
|
|
||||||
|
|
||||||
|
export async function initEnvVariables() {
|
||||||
|
const {config} = await import('dotenv')
|
||||||
|
config({ path: __dirname + '/../../.env' })
|
||||||
|
refreshConfig()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { createSupabaseDirectClient } from './supabase/init'
|
|||||||
import { getNotificationDestinationsForUser } from 'common/user-notification-preferences'
|
import { getNotificationDestinationsForUser } from 'common/user-notification-preferences'
|
||||||
import { Notification } from 'common/notifications'
|
import { Notification } from 'common/notifications'
|
||||||
import { insertNotificationToSupabase } from './supabase/notifications'
|
import { insertNotificationToSupabase } from './supabase/notifications'
|
||||||
import { getProfile } from './love/supabase'
|
import { getProfile } from 'shared/profiles/supabase'
|
||||||
|
|
||||||
export const createLoveLikeNotification = async (like: Row<'love_likes'>) => {
|
export const createProfileLikeNotification = async (like: Row<'profile_likes'>) => {
|
||||||
const { creator_id, target_id, like_id } = like
|
const { creator_id, target_id, like_id } = like
|
||||||
|
|
||||||
const targetPrivateUser = await getPrivateUser(target_id)
|
const targetPrivateUser = await getPrivateUser(target_id)
|
||||||
@@ -16,7 +16,7 @@ export const createLoveLikeNotification = async (like: Row<'love_likes'>) => {
|
|||||||
|
|
||||||
const { sendToBrowser } = getNotificationDestinationsForUser(
|
const { sendToBrowser } = getNotificationDestinationsForUser(
|
||||||
targetPrivateUser,
|
targetPrivateUser,
|
||||||
'new_love_like'
|
'new_profile_like'
|
||||||
)
|
)
|
||||||
if (!sendToBrowser) return
|
if (!sendToBrowser) return
|
||||||
|
|
||||||
@@ -24,11 +24,11 @@ export const createLoveLikeNotification = async (like: Row<'love_likes'>) => {
|
|||||||
const notification: Notification = {
|
const notification: Notification = {
|
||||||
id,
|
id,
|
||||||
userId: target_id,
|
userId: target_id,
|
||||||
reason: 'new_love_like',
|
reason: 'new_profile_like',
|
||||||
createdTime: Date.now(),
|
createdTime: Date.now(),
|
||||||
isSeen: false,
|
isSeen: false,
|
||||||
sourceId: like_id,
|
sourceId: like_id,
|
||||||
sourceType: 'love_like',
|
sourceType: 'profile_like',
|
||||||
sourceUpdateType: 'created',
|
sourceUpdateType: 'created',
|
||||||
sourceUserName: profile.user.name,
|
sourceUserName: profile.user.name,
|
||||||
sourceUserUsername: profile.user.username,
|
sourceUserUsername: profile.user.username,
|
||||||
@@ -39,8 +39,8 @@ export const createLoveLikeNotification = async (like: Row<'love_likes'>) => {
|
|||||||
return await insertNotificationToSupabase(notification, pg)
|
return await insertNotificationToSupabase(notification, pg)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createLoveShipNotification = async (
|
export const createProfileShipNotification = async (
|
||||||
ship: Row<'love_ships'>,
|
ship: Row<'profile_ships'>,
|
||||||
recipientId: string
|
recipientId: string
|
||||||
) => {
|
) => {
|
||||||
const { creator_id, target1_id, target2_id, ship_id } = ship
|
const { creator_id, target1_id, target2_id, ship_id } = ship
|
||||||
@@ -61,7 +61,7 @@ export const createLoveShipNotification = async (
|
|||||||
|
|
||||||
const { sendToBrowser } = getNotificationDestinationsForUser(
|
const { sendToBrowser } = getNotificationDestinationsForUser(
|
||||||
targetPrivateUser,
|
targetPrivateUser,
|
||||||
'new_love_ship'
|
'new_profile_ship'
|
||||||
)
|
)
|
||||||
if (!sendToBrowser) return
|
if (!sendToBrowser) return
|
||||||
|
|
||||||
@@ -69,11 +69,11 @@ export const createLoveShipNotification = async (
|
|||||||
const notification: Notification = {
|
const notification: Notification = {
|
||||||
id,
|
id,
|
||||||
userId: recipientId,
|
userId: recipientId,
|
||||||
reason: 'new_love_ship',
|
reason: 'new_profile_ship',
|
||||||
createdTime: Date.now(),
|
createdTime: Date.now(),
|
||||||
isSeen: false,
|
isSeen: false,
|
||||||
sourceId: ship_id,
|
sourceId: ship_id,
|
||||||
sourceType: 'love_ship',
|
sourceType: 'profile_ship',
|
||||||
sourceUpdateType: 'created',
|
sourceUpdateType: 'created',
|
||||||
sourceUserName: profile.user.name,
|
sourceUserName: profile.user.name,
|
||||||
sourceUserUsername: profile.user.username,
|
sourceUserUsername: profile.user.username,
|
||||||
58
backend/shared/src/encryption.ts
Normal file
58
backend/shared/src/encryption.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import crypto from "crypto";
|
||||||
|
import {ENV_CONFIG} from "common/envs/constants";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MASTER_KEY must be a 32-byte Buffer (AES-256).
|
||||||
|
* Load it from a secrets manager at runtime; do NOT hardcode.
|
||||||
|
*/
|
||||||
|
let _MASTER_KEY: Buffer | null = null
|
||||||
|
const getMasterKey = () => {
|
||||||
|
if (_MASTER_KEY) return _MASTER_KEY
|
||||||
|
|
||||||
|
if (ENV_CONFIG.dbEncryptionKey) {
|
||||||
|
const MASTER_KEY_BASE64 = ENV_CONFIG.dbEncryptionKey
|
||||||
|
_MASTER_KEY = Buffer.from(MASTER_KEY_BASE64, "base64")
|
||||||
|
if (_MASTER_KEY.length !== 32) throw new Error("MASTER_KEY must be 32 bytes")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_MASTER_KEY) throw new Error("MASTER_KEY not set")
|
||||||
|
|
||||||
|
return _MASTER_KEY
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt a UTF-8 message string into base64 ciphertext + iv + tag.
|
||||||
|
* The IV makes the encryption probabilistic to ensure uniqueness in ciphertexts even when encrypting the same plaintext
|
||||||
|
* multiple times and has therefore no intent of being secret. The authentication tag works similar to a MAC.
|
||||||
|
* It's used to prove the authenticity and integrity of a message
|
||||||
|
*/
|
||||||
|
export function encryptMessage(plaintext: string) {
|
||||||
|
const iv = crypto.randomBytes(12); // 96-bit IV, recommended for AES-GCM
|
||||||
|
const cipher = crypto.createCipheriv("aes-256-gcm", getMasterKey(), iv);
|
||||||
|
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||||||
|
const tag = cipher.getAuthTag();
|
||||||
|
|
||||||
|
// console.debug(plaintext, iv, ciphertext, tag)
|
||||||
|
|
||||||
|
return {
|
||||||
|
ciphertext: ciphertext.toString("base64"),
|
||||||
|
iv: iv.toString("base64"),
|
||||||
|
tag: tag.toString("base64"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt base64 ciphertext + iv + tag -> plaintext string.
|
||||||
|
* Throws on auth failure.
|
||||||
|
*/
|
||||||
|
export function decryptMessage({ciphertext, iv, tag}: { ciphertext: string; iv: string; tag: string; }) {
|
||||||
|
const ivBuf = Buffer.from(iv, "base64");
|
||||||
|
const ctBuf = Buffer.from(ciphertext, "base64");
|
||||||
|
const tagBuf = Buffer.from(tag, "base64");
|
||||||
|
|
||||||
|
const decipher = crypto.createDecipheriv("aes-256-gcm", getMasterKey(), ivBuf);
|
||||||
|
decipher.setAuthTag(tagBuf);
|
||||||
|
const plaintext = Buffer.concat([decipher.update(ctBuf), decipher.final()]).toString("utf8");
|
||||||
|
// console.debug("Decrypted message:", plaintext);
|
||||||
|
return plaintext;
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import {ENV_CONFIG, getStorageBucketId} from "common/envs/constants";
|
|||||||
|
|
||||||
export const getServiceAccountCredentials = () => {
|
export const getServiceAccountCredentials = () => {
|
||||||
let keyPath = ENV_CONFIG.googleApplicationCredentials
|
let keyPath = ENV_CONFIG.googleApplicationCredentials
|
||||||
// console.log('Using GOOGLE_APPLICATION_CREDENTIALS:', keyPath)
|
// console.debug('Using GOOGLE_APPLICATION_CREDENTIALS:', keyPath)
|
||||||
if (!keyPath) {
|
if (!keyPath) {
|
||||||
// throw new Error(
|
// throw new Error(
|
||||||
// `Please set the GOOGLE_APPLICATION_CREDENTIALS environment variable to contain the path to your key file.`
|
// `Please set the GOOGLE_APPLICATION_CREDENTIALS environment variable to contain the path to your key file.`
|
||||||
@@ -16,7 +16,7 @@ export const getServiceAccountCredentials = () => {
|
|||||||
if (!keyPath.startsWith('/')) {
|
if (!keyPath.startsWith('/')) {
|
||||||
// Make relative paths relative to the current file
|
// Make relative paths relative to the current file
|
||||||
keyPath = __dirname + '/' + keyPath
|
keyPath = __dirname + '/' + keyPath
|
||||||
// console.log(keyPath)
|
// console.debug(keyPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -41,11 +41,11 @@ export async function deleteUserFiles(username: string) {
|
|||||||
const [files] = await bucket.getFiles({prefix: path});
|
const [files] = await bucket.getFiles({prefix: path});
|
||||||
|
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
console.log(`No files found in bucket for user ${username}`);
|
console.debug(`No files found in bucket for user ${username}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await Promise.all(files.map(file => file.delete()));
|
await Promise.all(files.map(file => file.delete()));
|
||||||
console.log(`Deleted ${files.length} files for user ${username}`);
|
console.debug(`Deleted ${files.length} files for user ${username}`);
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -25,7 +25,7 @@ export const generateAvatarUrl = async (
|
|||||||
const buffer = await res.arrayBuffer()
|
const buffer = await res.arrayBuffer()
|
||||||
return await upload(userId, Buffer.from(buffer), bucket)
|
return await upload(userId, Buffer.from(buffer), bucket)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log('error generating avatar', e)
|
console.debug('error generating avatar', e)
|
||||||
return `https://${DOMAIN}/images/default-avatar.png`
|
return `https://${DOMAIN}/images/default-avatar.png`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export const constructPrefixTsQuery = (term: string) => {
|
|||||||
.replace(/'/g, "''")
|
.replace(/'/g, "''")
|
||||||
.replace(/[!&|():*<>]/g, '')
|
.replace(/[!&|():*<>]/g, '')
|
||||||
.trim()
|
.trim()
|
||||||
console.log(`Term: "${sanitized}"`)
|
console.debug(`Term: "${sanitized}"`)
|
||||||
if (sanitized === '') return ''
|
if (sanitized === '') return ''
|
||||||
const tokens = sanitized.split(/\s+/)
|
const tokens = sanitized.split(/\s+/)
|
||||||
return tokens.join(' & ') + ':*'
|
return tokens.join(' & ') + ':*'
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ export const initAdmin = () => {
|
|||||||
if (IS_LOCAL) {
|
if (IS_LOCAL) {
|
||||||
try {
|
try {
|
||||||
const serviceAccount = getServiceAccountCredentials()
|
const serviceAccount = getServiceAccountCredentials()
|
||||||
// console.log(serviceAccount)
|
// console.debug(serviceAccount)
|
||||||
if (!serviceAccount.project_id) {
|
if (!serviceAccount.project_id) {
|
||||||
console.log(`GOOGLE_APPLICATION_CREDENTIALS not set, skipping admin firebase init.`)
|
console.debug(`GOOGLE_APPLICATION_CREDENTIALS not set, skipping admin firebase init.`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
console.log(`Initializing connection to ${serviceAccount.project_id} Firebase...`)
|
console.debug(`Initializing connection to ${serviceAccount.project_id} Firebase...`)
|
||||||
return admin.initializeApp({
|
return admin.initializeApp({
|
||||||
projectId: serviceAccount.project_id,
|
projectId: serviceAccount.project_id,
|
||||||
credential: admin.credential.cert(serviceAccount),
|
credential: admin.credential.cert(serviceAccount),
|
||||||
@@ -25,6 +25,6 @@ export const initAdmin = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`Initializing connection to default Firebase...`)
|
console.debug(`Initializing connection to default Firebase...`)
|
||||||
return admin.initializeApp()
|
return admin.initializeApp()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ function writeLog(
|
|||||||
// record error properties in GCP if you just do log(err)
|
// record error properties in GCP if you just do log(err)
|
||||||
output['error'] = msg
|
output['error'] = msg
|
||||||
}
|
}
|
||||||
console.log(JSON.stringify(output, replacer))
|
console.debug(JSON.stringify(output, replacer))
|
||||||
} else {
|
} else {
|
||||||
const category = Object.values(pick(data, DISPLAY_CATEGORY_KEYS)).join()
|
const category = Object.values(pick(data, DISPLAY_CATEGORY_KEYS)).join()
|
||||||
const categoryLabel = category ? dim(category) + ' ' : ''
|
const categoryLabel = category ? dim(category) + ' ' : ''
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export class MetricWriter {
|
|||||||
for (const entry of freshEntries) {
|
for (const entry of freshEntries) {
|
||||||
entry.fresh = false
|
entry.fresh = false
|
||||||
}
|
}
|
||||||
if (!IS_GOOGLE_CLOUD) {
|
if (IS_GOOGLE_CLOUD) {
|
||||||
log.debug('Writing GCP metrics.', {entries: freshEntries})
|
log.debug('Writing GCP metrics.', {entries: freshEntries})
|
||||||
if (this.instance == null) {
|
if (this.instance == null) {
|
||||||
this.instance = await getInstanceInfo()
|
this.instance = await getInstanceInfo()
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { areGenderCompatible } from 'common/love/compatibility-util'
|
import { areGenderCompatible } from 'common/profiles/compatibility-util'
|
||||||
import { type Profile, type ProfileRow } from 'common/love/profile'
|
import { type Profile, type ProfileRow } from 'common/profiles/profile'
|
||||||
import { type User } from 'common/user'
|
import { type User } from 'common/user'
|
||||||
import { Row } from 'common/supabase/utils'
|
import { Row } from 'common/supabase/utils'
|
||||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||||
@@ -14,20 +14,24 @@ export function convertRow(row: ProfileAndUserRow): Profile
|
|||||||
export function convertRow(row: ProfileAndUserRow | undefined): Profile | null {
|
export function convertRow(row: ProfileAndUserRow | undefined): Profile | null {
|
||||||
if (!row) return null
|
if (!row) return null
|
||||||
|
|
||||||
return {
|
// Remove internal/search-only fields from the returned profile row
|
||||||
|
const profile: any = {
|
||||||
...row,
|
...row,
|
||||||
user: { ...row.user, name: row.name, username: row.username } as User,
|
user: { ...row.user, name: row.name, username: row.username } as User,
|
||||||
} as Profile
|
}
|
||||||
|
delete profile.bio_text
|
||||||
|
delete profile.bio_tsv
|
||||||
|
return profile as Profile
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOVER_COLS = 'profiles.*, name, username, users.data as user'
|
const PROFILE_COLS = 'profiles.*, name, username, users.data as user'
|
||||||
|
|
||||||
export const getProfile = async (userId: string) => {
|
export const getProfile = async (userId: string) => {
|
||||||
const pg = createSupabaseDirectClient()
|
const pg = createSupabaseDirectClient()
|
||||||
return await pg.oneOrNone(
|
return await pg.oneOrNone(
|
||||||
`
|
`
|
||||||
select
|
select
|
||||||
${LOVER_COLS}
|
${PROFILE_COLS}
|
||||||
from
|
from
|
||||||
profiles
|
profiles
|
||||||
join
|
join
|
||||||
@@ -45,7 +49,7 @@ export const getProfiles = async (userIds: string[]) => {
|
|||||||
return await pg.map(
|
return await pg.map(
|
||||||
`
|
`
|
||||||
select
|
select
|
||||||
${LOVER_COLS}
|
${PROFILE_COLS}
|
||||||
from
|
from
|
||||||
profiles
|
profiles
|
||||||
join
|
join
|
||||||
@@ -63,7 +67,7 @@ export const getGenderCompatibleProfiles = async (profile: ProfileRow) => {
|
|||||||
const profiles = await pg.map(
|
const profiles = await pg.map(
|
||||||
`
|
`
|
||||||
select
|
select
|
||||||
${LOVER_COLS}
|
${PROFILE_COLS}
|
||||||
from profiles
|
from profiles
|
||||||
join
|
join
|
||||||
users on users.id = profiles.user_id
|
users on users.id = profiles.user_id
|
||||||
@@ -88,7 +92,7 @@ export const getCompatibleProfiles = async (
|
|||||||
return await pg.map(
|
return await pg.map(
|
||||||
`
|
`
|
||||||
select
|
select
|
||||||
${LOVER_COLS}
|
${PROFILE_COLS}
|
||||||
from profiles
|
from profiles
|
||||||
join
|
join
|
||||||
users on users.id = profiles.user_id
|
users on users.id = profiles.user_id
|
||||||
@@ -118,9 +122,9 @@ export const getCompatibleProfiles = async (
|
|||||||
|
|
||||||
export const getCompatibilityAnswers = async (userIds: string[]) => {
|
export const getCompatibilityAnswers = async (userIds: string[]) => {
|
||||||
const pg = createSupabaseDirectClient()
|
const pg = createSupabaseDirectClient()
|
||||||
return await pg.manyOrNone<Row<'love_compatibility_answers'>>(
|
return await pg.manyOrNone<Row<'compatibility_answers'>>(
|
||||||
`
|
`
|
||||||
select * from love_compatibility_answers
|
select * from compatibility_answers
|
||||||
where creator_id = any($1)
|
where creator_id = any($1)
|
||||||
`,
|
`,
|
||||||
[userIds]
|
[userIds]
|
||||||
@@ -11,7 +11,7 @@ export {SupabaseClient} from 'common/supabase/utils'
|
|||||||
export const pgp = pgPromise({
|
export const pgp = pgPromise({
|
||||||
error(err: any, e: pgPromise.IEventContext) {
|
error(err: any, e: pgPromise.IEventContext) {
|
||||||
// Read more: https://node-postgres.com/apis/pool#error
|
// Read more: https://node-postgres.com/apis/pool#error
|
||||||
log.error('pgPromise background error', {
|
log.error(`pgPromise background error: ${err?.detail}`, {
|
||||||
error: err,
|
error: err,
|
||||||
event: e,
|
event: e,
|
||||||
})
|
})
|
||||||
@@ -64,7 +64,7 @@ const newClient = (
|
|||||||
...settings,
|
...settings,
|
||||||
}
|
}
|
||||||
|
|
||||||
// console.log(config)
|
// console.debug(config)
|
||||||
|
|
||||||
return pgp(config)
|
return pgp(config)
|
||||||
}
|
}
|
||||||
|
|||||||
48
backend/shared/src/supabase/messages.ts
Normal file
48
backend/shared/src/supabase/messages.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import {convertSQLtoTS, Row, tsToMillis} from "common/supabase/utils";
|
||||||
|
import {ChatMessage, PrivateChatMessage} from "common/chat-message";
|
||||||
|
import {decryptMessage} from "shared/encryption";
|
||||||
|
|
||||||
|
export type DbPrivateChatMessage = PrivateChatMessage & {
|
||||||
|
ciphertext: string
|
||||||
|
iv: string
|
||||||
|
tag: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const convertChatMessage = (row: Row<'private_user_messages'>) =>
|
||||||
|
convertSQLtoTS<'private_user_messages', ChatMessage>(row, {
|
||||||
|
created_time: tsToMillis as any,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const convertPrivateChatMessage = (row: Row<'private_user_messages'>) => {
|
||||||
|
const message = convertSQLtoTS<'private_user_messages', DbPrivateChatMessage>(
|
||||||
|
row,
|
||||||
|
{created_time: tsToMillis as any,}
|
||||||
|
);
|
||||||
|
parseMessageObject(message);
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
type MessageObject = Omit<ChatMessage, "id"> & { id: number; createdTimeTs: string } & {
|
||||||
|
ciphertext: string;
|
||||||
|
iv: string;
|
||||||
|
tag: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMessageObject(message: MessageObject) {
|
||||||
|
if (message.ciphertext && message.iv && message.tag) {
|
||||||
|
const plaintText = decryptMessage({
|
||||||
|
ciphertext: message.ciphertext,
|
||||||
|
iv: message.iv,
|
||||||
|
tag: message.tag,
|
||||||
|
});
|
||||||
|
message.content = JSON.parse(plaintText)
|
||||||
|
delete (message as any).ciphertext
|
||||||
|
delete (message as any).iv
|
||||||
|
delete (message as any).tag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDecryptedMessage(message: MessageObject) {
|
||||||
|
parseMessageObject(message)
|
||||||
|
return message.content
|
||||||
|
}
|
||||||
@@ -12,10 +12,15 @@ import {
|
|||||||
import {IS_LOCAL} from "common/envs/constants";
|
import {IS_LOCAL} from "common/envs/constants";
|
||||||
import {getWebsocketUrl} from "common/api/utils";
|
import {getWebsocketUrl} from "common/api/utils";
|
||||||
|
|
||||||
|
// Extend the type definition locally
|
||||||
|
interface HeartbeatWebSocket extends WebSocket {
|
||||||
|
isAlive?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
const SWITCHBOARD = new Switchboard()
|
const SWITCHBOARD = new Switchboard()
|
||||||
|
|
||||||
// if a connection doesn't ping for this long, we assume the other side is toast
|
// if a connection doesn't ping for this long, we assume the other side is toast
|
||||||
const CONNECTION_TIMEOUT_MS = 60 * 1000
|
// const CONNECTION_TIMEOUT_MS = 60 * 1000
|
||||||
|
|
||||||
export class MessageParseError extends Error {
|
export class MessageParseError extends Error {
|
||||||
details?: unknown
|
details?: unknown
|
||||||
@@ -52,7 +57,7 @@ function parseMessage(data: RawData): ClientMessage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function processMessage(ws: WebSocket, data: RawData): ServerMessage<'ack'> {
|
function processMessage(ws: HeartbeatWebSocket, data: RawData): ServerMessage<'ack'> {
|
||||||
try {
|
try {
|
||||||
const msg = parseMessage(data)
|
const msg = parseMessage(data)
|
||||||
const { type, txid } = msg
|
const { type, txid } = msg
|
||||||
@@ -129,21 +134,26 @@ export function listen(server: HttpServer, path: string) {
|
|||||||
let deadConnectionCleaner: NodeJS.Timeout | undefined
|
let deadConnectionCleaner: NodeJS.Timeout | undefined
|
||||||
wss.on('listening', () => {
|
wss.on('listening', () => {
|
||||||
log.info(`Web socket server listening on ${path}. ${getWebsocketUrl()}`)
|
log.info(`Web socket server listening on ${path}. ${getWebsocketUrl()}`)
|
||||||
deadConnectionCleaner = setInterval(function ping() {
|
deadConnectionCleaner = setInterval(() => {
|
||||||
const now = Date.now()
|
for (const ws of wss.clients as Set<HeartbeatWebSocket>) {
|
||||||
for (const ws of wss.clients) {
|
if (ws.isAlive === false) {
|
||||||
const lastSeen = SWITCHBOARD.getClient(ws).lastSeen
|
log.debug('Terminating dead connection');
|
||||||
if (lastSeen < now - CONNECTION_TIMEOUT_MS) {
|
ws.terminate();
|
||||||
ws.terminate()
|
continue;
|
||||||
}
|
}
|
||||||
|
ws.isAlive = false;
|
||||||
|
// log.debug('Sending ping to client');
|
||||||
|
ws.ping();
|
||||||
}
|
}
|
||||||
}, CONNECTION_TIMEOUT_MS)
|
}, 25000);
|
||||||
})
|
})
|
||||||
wss.on('error', (err) => {
|
wss.on('error', (err) => {
|
||||||
log.error('Error on websocket server.', { error: err })
|
log.error('Error on websocket server.', { error: err })
|
||||||
})
|
})
|
||||||
wss.on('connection', (ws) => {
|
wss.on('connection', (ws: HeartbeatWebSocket) => {
|
||||||
// todo: should likely kill connections that haven't sent any ping for a long time
|
ws.isAlive = true;
|
||||||
|
// log.debug('Received pong from client');
|
||||||
|
ws.on('pong', () => (ws.isAlive = true));
|
||||||
metrics.inc('ws/connections_established')
|
metrics.inc('ws/connections_established')
|
||||||
metrics.set('ws/open_connections', wss.clients.size)
|
metrics.set('ws/open_connections', wss.clients.size)
|
||||||
log.debug('WS client connected.')
|
log.debug('WS client connected.')
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ CREATE TABLE IF NOT EXISTS bookmarked_searches (
|
|||||||
search_name TEXT DEFAULT NULL
|
search_name TEXT DEFAULT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ALTER TABLE bookmarked_searches
|
||||||
|
ADD CONSTRAINT bookmarked_searches_creator_id_fkey
|
||||||
|
FOREIGN KEY (creator_id)
|
||||||
|
REFERENCES users(id)
|
||||||
|
ON DELETE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
-- Row Level Security
|
-- Row Level Security
|
||||||
ALTER TABLE bookmarked_searches ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE bookmarked_searches ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
@@ -17,17 +24,17 @@ DROP POLICY IF EXISTS "public read" ON bookmarked_searches;
|
|||||||
CREATE POLICY "public read" ON bookmarked_searches
|
CREATE POLICY "public read" ON bookmarked_searches
|
||||||
FOR SELECT USING (true);
|
FOR SELECT USING (true);
|
||||||
|
|
||||||
DROP POLICY IF EXISTS "self delete" ON bookmarked_searches;
|
-- DROP POLICY IF EXISTS "self delete" ON bookmarked_searches;
|
||||||
CREATE POLICY "self delete" ON bookmarked_searches
|
-- CREATE POLICY "self delete" ON bookmarked_searches
|
||||||
FOR DELETE USING (creator_id = firebase_uid());
|
-- FOR DELETE USING (creator_id = firebase_uid());
|
||||||
|
--
|
||||||
DROP POLICY IF EXISTS "self insert" ON bookmarked_searches;
|
-- DROP POLICY IF EXISTS "self insert" ON bookmarked_searches;
|
||||||
CREATE POLICY "self insert" ON bookmarked_searches
|
-- CREATE POLICY "self insert" ON bookmarked_searches
|
||||||
FOR INSERT WITH CHECK (creator_id = firebase_uid());
|
-- FOR INSERT WITH CHECK (creator_id = firebase_uid());
|
||||||
|
--
|
||||||
DROP POLICY IF EXISTS "self update" ON bookmarked_searches;
|
-- DROP POLICY IF EXISTS "self update" ON bookmarked_searches;
|
||||||
CREATE POLICY "self update" ON bookmarked_searches
|
-- CREATE POLICY "self update" ON bookmarked_searches
|
||||||
FOR UPDATE USING (creator_id = firebase_uid());
|
-- FOR UPDATE USING (creator_id = firebase_uid());
|
||||||
|
|
||||||
-- Indexes
|
-- Indexes
|
||||||
CREATE INDEX IF NOT EXISTS bookmarked_searches_creator_id_created_time_idx
|
CREATE INDEX IF NOT EXISTS bookmarked_searches_creator_id_created_time_idx
|
||||||
|
|||||||
52
backend/supabase/compatibility_answers.sql
Normal file
52
backend/supabase/compatibility_answers.sql
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS compatibility_answers (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
|
creator_id TEXT NOT NULL,
|
||||||
|
explanation TEXT,
|
||||||
|
importance INTEGER NOT NULL,
|
||||||
|
multiple_choice INTEGER NOT NULL,
|
||||||
|
pref_choices INTEGER[] NOT NULL,
|
||||||
|
question_id BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE compatibility_answers
|
||||||
|
ADD CONSTRAINT compatibility_answers_creator_id_fkey
|
||||||
|
FOREIGN KEY (creator_id)
|
||||||
|
REFERENCES users(id)
|
||||||
|
ON DELETE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
-- Row Level Security
|
||||||
|
ALTER TABLE compatibility_answers ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
ALTER TABLE compatibility_answers
|
||||||
|
ADD CONSTRAINT unique_question_creator
|
||||||
|
UNIQUE (question_id, creator_id);
|
||||||
|
|
||||||
|
|
||||||
|
-- Policies
|
||||||
|
DROP POLICY IF EXISTS "public read" ON compatibility_answers;
|
||||||
|
CREATE POLICY "public read" ON compatibility_answers
|
||||||
|
FOR SELECT USING (true);
|
||||||
|
|
||||||
|
-- DROP POLICY IF EXISTS "self delete" ON compatibility_answers;
|
||||||
|
-- CREATE POLICY "self delete" ON compatibility_answers
|
||||||
|
-- FOR DELETE USING (creator_id = firebase_uid());
|
||||||
|
--
|
||||||
|
-- DROP POLICY IF EXISTS "self insert" ON compatibility_answers;
|
||||||
|
-- CREATE POLICY "self insert" ON compatibility_answers
|
||||||
|
-- FOR INSERT WITH CHECK (creator_id = firebase_uid());
|
||||||
|
--
|
||||||
|
-- DROP POLICY IF EXISTS "self update" ON compatibility_answers;
|
||||||
|
-- CREATE POLICY "self update" ON compatibility_answers
|
||||||
|
-- FOR UPDATE USING (creator_id = firebase_uid());
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS compatibility_answers_creator_id_created_time_idx
|
||||||
|
ON public.compatibility_answers (creator_id, created_time DESC);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS compatibility_answers_question_creator_unique
|
||||||
|
ON public.compatibility_answers (question_id, creator_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS compatibility_answers_question_id_idx
|
||||||
|
ON public.compatibility_answers (question_id);
|
||||||
45
backend/supabase/compatibility_answers_free.sql
Normal file
45
backend/supabase/compatibility_answers_free.sql
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS compatibility_answers_free (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
|
creator_id TEXT NOT NULL,
|
||||||
|
free_response TEXT,
|
||||||
|
integer INTEGER,
|
||||||
|
multiple_choice INTEGER,
|
||||||
|
question_id BIGINT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE compatibility_answers_free
|
||||||
|
ADD CONSTRAINT compatibility_answers_free_creator_id_fkey
|
||||||
|
FOREIGN KEY (creator_id)
|
||||||
|
REFERENCES users(id)
|
||||||
|
ON DELETE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
-- Row Level Security
|
||||||
|
ALTER TABLE compatibility_answers_free ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Policies
|
||||||
|
DROP POLICY IF EXISTS "public read" ON compatibility_answers_free;
|
||||||
|
CREATE POLICY "public read" ON compatibility_answers_free FOR SELECT USING (true);
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "self delete" ON compatibility_answers_free;
|
||||||
|
CREATE POLICY "self delete" ON compatibility_answers_free FOR DELETE USING (creator_id = firebase_uid());
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "self insert" ON compatibility_answers_free;
|
||||||
|
CREATE POLICY "self insert" ON compatibility_answers_free FOR INSERT WITH CHECK (creator_id = firebase_uid());
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "self update" ON compatibility_answers_free;
|
||||||
|
CREATE POLICY "self update" ON compatibility_answers_free FOR UPDATE USING (creator_id = firebase_uid());
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
DROP INDEX IF EXISTS compatibility_answers_free_creator_id_created_time_idx;
|
||||||
|
CREATE INDEX IF NOT EXISTS compatibility_answers_free_creator_id_created_time_idx
|
||||||
|
ON public.compatibility_answers_free USING btree (creator_id, created_time DESC);
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS compatibility_answers_free_question_creator_unique;
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS compatibility_answers_free_question_creator_unique
|
||||||
|
ON public.compatibility_answers_free USING btree (question_id, creator_id);
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS compatibility_answers_free_question_id_idx;
|
||||||
|
CREATE INDEX IF NOT EXISTS compatibility_answers_free_question_id_idx
|
||||||
|
ON public.compatibility_answers_free USING btree (question_id);
|
||||||
28
backend/supabase/compatibility_prompts.sql
Normal file
28
backend/supabase/compatibility_prompts.sql
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS compatibility_prompts (
|
||||||
|
answer_type TEXT DEFAULT 'free_response' NOT NULL,
|
||||||
|
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
|
creator_id TEXT,
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
importance_score NUMERIC DEFAULT 0 NOT NULL,
|
||||||
|
multiple_choice_options JSONB,
|
||||||
|
question TEXT NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
ALTER TABLE compatibility_prompts
|
||||||
|
ADD CONSTRAINT compatibility_prompts_creator_id_fkey
|
||||||
|
FOREIGN KEY (creator_id)
|
||||||
|
REFERENCES users(id)
|
||||||
|
ON DELETE SET NULL;
|
||||||
|
|
||||||
|
|
||||||
|
-- Row Level Security
|
||||||
|
ALTER TABLE compatibility_prompts ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Policies
|
||||||
|
DROP POLICY IF EXISTS "public read" ON compatibility_prompts;
|
||||||
|
CREATE POLICY "public read" ON compatibility_prompts
|
||||||
|
FOR SELECT USING (true);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
-- The primary key automatically creates a unique index on (id),
|
||||||
|
-- so the explicit index on id is redundant and removed.
|
||||||
14
backend/supabase/contact.sql
Normal file
14
backend/supabase/contact.sql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
create table if not exists
|
||||||
|
contact (
|
||||||
|
id text default uuid_generate_v4 () not null primary key,
|
||||||
|
created_time timestamp with time zone default now(),
|
||||||
|
user_id text,
|
||||||
|
content jsonb
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Foreign Keys
|
||||||
|
alter table contact
|
||||||
|
add constraint contact_user_id_fkey foreign key (user_id) references users (id);
|
||||||
|
|
||||||
|
-- Row Level Security
|
||||||
|
alter table contact enable row level security;
|
||||||
@@ -2,15 +2,15 @@
|
|||||||
create
|
create
|
||||||
or replace function public.to_jsonb (jsonb) returns jsonb language sql immutable parallel SAFE strict as $function$ select $1 $function$;
|
or replace function public.to_jsonb (jsonb) returns jsonb language sql immutable parallel SAFE strict as $function$ select $1 $function$;
|
||||||
|
|
||||||
create
|
-- create
|
||||||
or replace function public.ts_to_millis (ts timestamp without time zone) returns bigint language sql immutable parallel SAFE as $function$
|
-- or replace function public.ts_to_millis (ts timestamp without time zone) returns bigint language sql immutable parallel SAFE as $function$
|
||||||
select extract(epoch from ts)::bigint * 1000
|
-- select extract(epoch from ts)::bigint * 1000
|
||||||
$function$;
|
-- $function$;
|
||||||
|
--
|
||||||
create
|
-- create
|
||||||
or replace function public.ts_to_millis (ts timestamp with time zone) returns bigint language sql immutable parallel SAFE as $function$
|
-- or replace function public.ts_to_millis (ts timestamp with time zone) returns bigint language sql immutable parallel SAFE as $function$
|
||||||
select (extract(epoch from ts) * 1000)::bigint
|
-- select (extract(epoch from ts) * 1000)::bigint
|
||||||
$function$;
|
-- $function$;
|
||||||
|
|
||||||
create
|
create
|
||||||
or replace function public.millis_to_ts (millis bigint) returns timestamp with time zone language sql immutable parallel SAFE as $function$
|
or replace function public.millis_to_ts (millis bigint) returns timestamp with time zone language sql immutable parallel SAFE as $function$
|
||||||
|
|||||||
@@ -1,34 +1,34 @@
|
|||||||
|
|
||||||
|
|
||||||
create
|
create
|
||||||
or replace function public.get_compatibility_questions_with_answer_count () returns setof record language plpgsql as $function$
|
or replace function public.get_compatibility_prompts_with_answer_count () returns setof record language plpgsql as $function$
|
||||||
BEGIN
|
BEGIN
|
||||||
RETURN QUERY
|
RETURN QUERY
|
||||||
SELECT
|
SELECT
|
||||||
love_questions.*,
|
compatibility_prompts.*,
|
||||||
COUNT(love_compatibility_answers.question_id) as answer_count
|
COUNT(compatibility_answers.question_id) as answer_count
|
||||||
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 love_questions.answer_type='compatibility_multiple_choice'
|
WHERE compatibility_prompts.answer_type='compatibility_multiple_choice'
|
||||||
GROUP BY
|
GROUP BY
|
||||||
love_questions.id
|
compatibility_prompts.id
|
||||||
ORDER BY
|
ORDER BY
|
||||||
answer_count DESC;
|
answer_count DESC;
|
||||||
END;
|
END;
|
||||||
$function$;
|
$function$;
|
||||||
|
|
||||||
create
|
create
|
||||||
or replace function public.get_love_question_answers_and_profiles (p_question_id bigint) returns setof record language plpgsql as $function$
|
or replace function public.get_compatibility_answers_and_profiles (p_question_id bigint) returns setof record language plpgsql as $function$
|
||||||
BEGIN
|
BEGIN
|
||||||
RETURN QUERY
|
RETURN QUERY
|
||||||
SELECT
|
SELECT
|
||||||
love_answers.question_id,
|
compatibility_answers_free.question_id,
|
||||||
love_answers.created_time,
|
compatibility_answers_free.created_time,
|
||||||
love_answers.free_response,
|
compatibility_answers_free.free_response,
|
||||||
love_answers.multiple_choice,
|
compatibility_answers_free.multiple_choice,
|
||||||
love_answers.integer,
|
compatibility_answers_free.integer,
|
||||||
profiles.age,
|
profiles.age,
|
||||||
profiles.gender,
|
profiles.gender,
|
||||||
profiles.city,
|
profiles.city,
|
||||||
@@ -36,11 +36,11 @@ SELECT
|
|||||||
FROM
|
FROM
|
||||||
profiles
|
profiles
|
||||||
JOIN
|
JOIN
|
||||||
love_answers ON profiles.user_id = love_answers.creator_id
|
compatibility_answers_free ON profiles.user_id = compatibility_answers_free.creator_id
|
||||||
join
|
join
|
||||||
users on profiles.user_id = users.id
|
users on profiles.user_id = users.id
|
||||||
WHERE
|
WHERE
|
||||||
love_answers.question_id = p_question_id
|
compatibility_answers_free.question_id = p_question_id
|
||||||
order by love_answers.created_time desc;
|
order by compatibility_answers_free.created_time desc;
|
||||||
END;
|
END;
|
||||||
$function$;
|
$function$;
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
CREATE TABLE IF NOT EXISTS love_answers (
|
|
||||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
||||||
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
|
|
||||||
creator_id TEXT NOT NULL,
|
|
||||||
free_response TEXT,
|
|
||||||
integer INTEGER,
|
|
||||||
multiple_choice INTEGER,
|
|
||||||
question_id BIGINT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Row Level Security
|
|
||||||
ALTER TABLE love_answers ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
-- Policies
|
|
||||||
DROP POLICY IF EXISTS "public read" ON love_answers;
|
|
||||||
CREATE POLICY "public read" ON love_answers FOR SELECT USING (true);
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS "self delete" ON love_answers;
|
|
||||||
CREATE POLICY "self delete" ON love_answers FOR DELETE USING (creator_id = firebase_uid());
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS "self insert" ON love_answers;
|
|
||||||
CREATE POLICY "self insert" ON love_answers FOR INSERT WITH CHECK (creator_id = firebase_uid());
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS "self update" ON love_answers;
|
|
||||||
CREATE POLICY "self update" ON love_answers FOR UPDATE USING (creator_id = firebase_uid());
|
|
||||||
|
|
||||||
-- Indexes
|
|
||||||
DROP INDEX IF EXISTS love_answers_creator_id_created_time_idx;
|
|
||||||
CREATE INDEX IF NOT EXISTS love_answers_creator_id_created_time_idx
|
|
||||||
ON public.love_answers USING btree (creator_id, created_time DESC);
|
|
||||||
|
|
||||||
DROP INDEX IF EXISTS love_answers_question_creator_unique;
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS love_answers_question_creator_unique
|
|
||||||
ON public.love_answers USING btree (question_id, creator_id);
|
|
||||||
|
|
||||||
DROP INDEX IF EXISTS love_answers_question_id_idx;
|
|
||||||
CREATE INDEX IF NOT EXISTS love_answers_question_id_idx
|
|
||||||
ON public.love_answers USING btree (question_id);
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
CREATE TABLE IF NOT EXISTS love_compatibility_answers (
|
|
||||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
||||||
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
|
|
||||||
creator_id TEXT NOT NULL,
|
|
||||||
explanation TEXT,
|
|
||||||
importance INTEGER NOT NULL,
|
|
||||||
multiple_choice INTEGER NOT NULL,
|
|
||||||
pref_choices INTEGER[] NOT NULL,
|
|
||||||
question_id BIGINT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Row Level Security
|
|
||||||
ALTER TABLE love_compatibility_answers ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
ALTER TABLE love_compatibility_answers
|
|
||||||
ADD CONSTRAINT unique_question_creator
|
|
||||||
UNIQUE (question_id, creator_id);
|
|
||||||
|
|
||||||
|
|
||||||
-- Policies
|
|
||||||
DROP POLICY IF EXISTS "public read" ON love_compatibility_answers;
|
|
||||||
CREATE POLICY "public read" ON love_compatibility_answers
|
|
||||||
FOR SELECT USING (true);
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS "self delete" ON love_compatibility_answers;
|
|
||||||
CREATE POLICY "self delete" ON love_compatibility_answers
|
|
||||||
FOR DELETE USING (creator_id = firebase_uid());
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS "self insert" ON love_compatibility_answers;
|
|
||||||
CREATE POLICY "self insert" ON love_compatibility_answers
|
|
||||||
FOR INSERT WITH CHECK (creator_id = firebase_uid());
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS "self update" ON love_compatibility_answers;
|
|
||||||
CREATE POLICY "self update" ON love_compatibility_answers
|
|
||||||
FOR UPDATE USING (creator_id = firebase_uid());
|
|
||||||
|
|
||||||
-- Indexes
|
|
||||||
CREATE INDEX IF NOT EXISTS love_compatibility_answers_creator_id_created_time_idx
|
|
||||||
ON public.love_compatibility_answers (creator_id, created_time DESC);
|
|
||||||
|
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS love_compatibility_answers_question_creator_unique
|
|
||||||
ON public.love_compatibility_answers (question_id, creator_id);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS love_compatibility_answers_question_id_idx
|
|
||||||
ON public.love_compatibility_answers (question_id);
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
CREATE TABLE IF NOT EXISTS love_likes (
|
|
||||||
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
|
|
||||||
creator_id TEXT NOT NULL,
|
|
||||||
like_id TEXT DEFAULT random_alphanumeric(12) NOT NULL,
|
|
||||||
target_id TEXT NOT NULL,
|
|
||||||
CONSTRAINT love_likes_pkey PRIMARY KEY (creator_id, like_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Row Level Security
|
|
||||||
ALTER TABLE love_likes ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
-- Policies
|
|
||||||
DROP POLICY IF EXISTS "public read" ON love_likes;
|
|
||||||
CREATE POLICY "public read" ON love_likes
|
|
||||||
FOR SELECT USING (true);
|
|
||||||
|
|
||||||
-- Indexes
|
|
||||||
-- The primary key already creates a unique index on (creator_id, like_id)
|
|
||||||
-- so we do not recreate that. Additional indexes:
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS user_likes_target_id_raw
|
|
||||||
ON public.love_likes (target_id);
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
CREATE TABLE IF NOT EXISTS love_questions (
|
|
||||||
answer_type TEXT DEFAULT 'free_response' NOT NULL,
|
|
||||||
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
|
|
||||||
creator_id TEXT NOT NULL,
|
|
||||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
|
||||||
importance_score NUMERIC DEFAULT 0 NOT NULL,
|
|
||||||
multiple_choice_options JSONB,
|
|
||||||
question TEXT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Row Level Security
|
|
||||||
ALTER TABLE love_questions ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
-- Policies
|
|
||||||
DROP POLICY IF EXISTS "public read" ON love_questions;
|
|
||||||
CREATE POLICY "public read" ON love_questions
|
|
||||||
FOR ALL USING (true);
|
|
||||||
|
|
||||||
-- Indexes
|
|
||||||
-- The primary key automatically creates a unique index on (id),
|
|
||||||
-- so the explicit index on id is redundant and removed.
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
CREATE TABLE IF NOT EXISTS love_ships (
|
|
||||||
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
|
|
||||||
creator_id TEXT NOT NULL,
|
|
||||||
ship_id TEXT DEFAULT random_alphanumeric(12) NOT NULL,
|
|
||||||
target1_id TEXT NOT NULL,
|
|
||||||
target2_id TEXT NOT NULL,
|
|
||||||
CONSTRAINT love_ships_pkey PRIMARY KEY (creator_id, ship_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Row Level Security
|
|
||||||
ALTER TABLE love_ships ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
-- Policies
|
|
||||||
DROP POLICY IF EXISTS "public read" ON love_ships;
|
|
||||||
CREATE POLICY "public read" ON love_ships
|
|
||||||
FOR SELECT USING (true);
|
|
||||||
|
|
||||||
-- Indexes
|
|
||||||
-- Primary key automatically creates a unique index on (creator_id, ship_id), so no need to recreate it.
|
|
||||||
-- Keep additional indexes for query optimization:
|
|
||||||
DROP INDEX IF EXISTS love_ships_target1_id;
|
|
||||||
CREATE INDEX love_ships_target1_id ON public.love_ships USING btree (target1_id);
|
|
||||||
|
|
||||||
DROP INDEX IF EXISTS love_ships_target2_id;
|
|
||||||
CREATE INDEX love_ships_target2_id ON public.love_ships USING btree (target2_id);
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
CREATE TABLE IF NOT EXISTS love_stars (
|
|
||||||
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
|
|
||||||
creator_id TEXT NOT NULL,
|
|
||||||
star_id TEXT DEFAULT random_alphanumeric(12) NOT NULL,
|
|
||||||
target_id TEXT NOT NULL,
|
|
||||||
CONSTRAINT love_stars_pkey PRIMARY KEY (creator_id, star_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Row Level Security
|
|
||||||
ALTER TABLE love_stars ENABLE ROW LEVEL SECURITY;
|
|
||||||
|
|
||||||
-- Policies
|
|
||||||
DROP POLICY IF EXISTS "public read" ON love_stars;
|
|
||||||
CREATE POLICY "public read" ON love_stars
|
|
||||||
FOR SELECT USING (true);
|
|
||||||
|
|
||||||
-- Indexes
|
|
||||||
-- The primary key already creates a unique index on (creator_id, star_id), so no need to recreate it.
|
|
||||||
|
|
||||||
DROP INDEX IF EXISTS love_stars_target_id_idx;
|
|
||||||
CREATE INDEX love_stars_target_id_idx ON public.love_stars USING btree (target_id);
|
|
||||||
@@ -8,14 +8,14 @@ BEGIN;
|
|||||||
\i backend/supabase/private_users.sql
|
\i backend/supabase/private_users.sql
|
||||||
\i backend/supabase/private_user_messages.sql
|
\i backend/supabase/private_user_messages.sql
|
||||||
\i backend/supabase/private_user_seen_message_channels.sql
|
\i backend/supabase/private_user_seen_message_channels.sql
|
||||||
\i backend/supabase/love_answers.sql
|
\i backend/supabase/compatibility_answers_free.sql
|
||||||
\i backend/supabase/profile_comments.sql
|
\i backend/supabase/profile_comments.sql
|
||||||
\i backend/supabase/love_compatibility_answers.sql
|
\i backend/supabase/compatibility_answers.sql
|
||||||
\i backend/supabase/love_likes.sql
|
\i backend/supabase/profile_likes.sql
|
||||||
\i backend/supabase/love_questions.sql
|
\i backend/supabase/compatibility_prompts.sql
|
||||||
\i backend/supabase/love_ships.sql
|
\i backend/supabase/profile_ships.sql
|
||||||
\i backend/supabase/love_stars.sql
|
\i backend/supabase/profile_stars.sql
|
||||||
\i backend/supabase/love_waitlist.sql
|
\i backend/supabase/user_waitlist.sql
|
||||||
\i backend/supabase/user_events.sql
|
\i backend/supabase/user_events.sql
|
||||||
\i backend/supabase/user_notifications.sql
|
\i backend/supabase/user_notifications.sql
|
||||||
\i backend/supabase/functions_others.sql
|
\i backend/supabase/functions_others.sql
|
||||||
|
|||||||
0
backend/supabase/migrations/.keep
Normal file
0
backend/supabase/migrations/.keep
Normal file
@@ -10,6 +10,12 @@ CREATE TABLE IF NOT EXISTS private_user_message_channel_members (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- Foreign Keys
|
-- Foreign Keys
|
||||||
|
ALTER TABLE private_user_message_channel_members
|
||||||
|
ADD CONSTRAINT private_user_message_channel_members_user_id_fkey
|
||||||
|
FOREIGN KEY (user_id)
|
||||||
|
REFERENCES users(id)
|
||||||
|
ON DELETE CASCADE;
|
||||||
|
|
||||||
DO $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF NOT EXISTS (
|
IF NOT EXISTS (
|
||||||
@@ -30,12 +36,10 @@ END$$;
|
|||||||
ALTER TABLE private_user_message_channel_members ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE private_user_message_channel_members ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
-- Indexes
|
-- Indexes
|
||||||
DROP INDEX IF EXISTS pumcm_members_idx;
|
CREATE INDEX IF NOT EXISTS pumcm_members_idx
|
||||||
CREATE INDEX pumcm_members_idx
|
|
||||||
ON public.private_user_message_channel_members
|
ON public.private_user_message_channel_members
|
||||||
USING btree (channel_id, user_id);
|
USING btree (channel_id, user_id);
|
||||||
|
|
||||||
DROP INDEX IF EXISTS unique_user_channel;
|
CREATE UNIQUE INDEX IF NOT EXISTS unique_user_channel
|
||||||
CREATE UNIQUE INDEX unique_user_channel
|
|
||||||
ON public.private_user_message_channel_members
|
ON public.private_user_message_channel_members
|
||||||
USING btree (channel_id, user_id);
|
USING btree (channel_id, user_id);
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ CREATE TABLE IF NOT EXISTS private_user_message_channels (
|
|||||||
-- Row Level Security
|
-- Row Level Security
|
||||||
ALTER TABLE private_user_message_channels ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE private_user_message_channels ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
-- Policies
|
-- Policies
|
||||||
DROP POLICY IF EXISTS "public read" ON private_user_message_channels;
|
DROP POLICY IF EXISTS "public read" ON private_user_message_channels;
|
||||||
|
|
||||||
CREATE POLICY "public read" ON private_user_message_channels
|
CREATE POLICY "public read" ON private_user_message_channels
|
||||||
FOR ALL
|
FOR SELECT USING (true);
|
||||||
USING (true);
|
|
||||||
|
|
||||||
-- Indexes
|
-- Indexes
|
||||||
-- Removed redundant primary key index creation because PRIMARY KEY already creates a unique index on id
|
-- Removed redundant primary key index creation because PRIMARY KEY already creates a unique index on id
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
CREATE TABLE IF NOT EXISTS private_user_messages (
|
CREATE TABLE IF NOT EXISTS private_user_messages (
|
||||||
channel_id BIGINT NOT NULL,
|
channel_id BIGINT NOT NULL,
|
||||||
content JSONB NOT NULL,
|
content JSONB,
|
||||||
|
ciphertext text, -- base64
|
||||||
|
iv text, -- base64
|
||||||
|
tag text, -- base64 (GCM auth tag)
|
||||||
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
old_id BIGINT,
|
user_id TEXT,
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
visibility TEXT DEFAULT 'private'::TEXT NOT NULL,
|
visibility TEXT DEFAULT 'private'::TEXT NOT NULL,
|
||||||
CONSTRAINT private_user_messages_channel_id_fkey
|
CONSTRAINT private_user_messages_channel_id_fkey
|
||||||
FOREIGN KEY (channel_id)
|
FOREIGN KEY (channel_id)
|
||||||
@@ -12,11 +14,16 @@ CREATE TABLE IF NOT EXISTS private_user_messages (
|
|||||||
ON UPDATE CASCADE ON DELETE CASCADE
|
ON UPDATE CASCADE ON DELETE CASCADE
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ALTER TABLE private_user_messages
|
||||||
|
ADD CONSTRAINT private_user_messages_user_id_fkey
|
||||||
|
FOREIGN KEY (user_id)
|
||||||
|
REFERENCES users(id)
|
||||||
|
ON DELETE SET NULL;
|
||||||
|
|
||||||
|
|
||||||
-- Row Level Security
|
-- Row Level Security
|
||||||
ALTER TABLE private_user_messages ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE private_user_messages ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
-- Indexes
|
-- Indexes
|
||||||
DROP INDEX IF EXISTS private_user_messages_channel_id_idx;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS private_user_messages_channel_id_idx
|
CREATE INDEX IF NOT EXISTS private_user_messages_channel_id_idx
|
||||||
ON public.private_user_messages USING btree (channel_id, created_time DESC);
|
ON public.private_user_messages USING btree (channel_id, created_time DESC);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user