mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-04-04 06:51: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
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -68,6 +68,7 @@ email-preview
|
||||
*.jpeg
|
||||
*.gif
|
||||
*.svg
|
||||
*.ico
|
||||
*.mp4
|
||||
*.mov
|
||||
*.avi
|
||||
@@ -84,4 +85,5 @@ email-preview
|
||||
*.tfstate
|
||||
*.tfstate.backup
|
||||
*.terraform
|
||||
/backups/firebase/auth/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/cd.yml)
|
||||

|
||||

|
||||
|
||||
# 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.
|
||||
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
|
||||
|
||||
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] 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] Add dark theme
|
||||
- [ ] Add profile features (intellectual interests, cause areas, personality type, conflict style, etc.)
|
||||
- [ ] Add filters to search through remaining profile features (politics, religion, education level, etc.)
|
||||
- [ ] Cover with tests (very important, just the test template and framework are ready)
|
||||
- [ ] Add profile fields (intellectual interests, cause areas, personality type, conflict style, timezone, etc.)
|
||||
- [ ] Add filters to search through remaining profile fields (politics, religion, education level, etc.)
|
||||
- [ ] Cover with tests (crucial, just the test template and framework are ready)
|
||||
- [ ] Make the app more user-friendly and appealing (UI/UX)
|
||||
- [ ] Clean up terms and conditions (convert to Markdown)
|
||||
- [ ] Clean up privacy notice (convert to Markdown)
|
||||
- [ ] Add other authentication methods (GitHub, Facebook, Apple, phone, etc.)
|
||||
- [ ] Add email verification
|
||||
- [ ] Add password reset
|
||||
- [ ] Add automated welcome email
|
||||
- [x] Add automated welcome email
|
||||
- [ ] Security audit and penetration testing
|
||||
- [ ] Make `deploy-api.sh` run automatically on push to `main` branch
|
||||
- [ ] 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:
|
||||
- Database, otherwise anyone could access all the user data (including private messages)
|
||||
- Firebase, otherwise anyone could remove users or modify the media files
|
||||
- Email, analytics, and location services, otherwise anyone could use our paid plan
|
||||
- Email, analytics, and location services, otherwise anyone could use the service plans Compass paid for and run up the bill.
|
||||
|
||||
That's why we separate all those services between production and development environments, so that you can code freely without impacting the functioning of the deployed platform.
|
||||
Contributors should use the default keys for local development. Production uses a separate environment with stricter rules and private keys that are not shared.
|
||||
@@ -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!
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
|
||||
## 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" \
|
||||
--role="roles/secretmanager.secretAccessor"
|
||||
gcloud run services list
|
||||
gcloud compute backend-services update api-backend \
|
||||
--global \
|
||||
--timeout=600s
|
||||
```
|
||||
|
||||
Set up the saved search notifications job:
|
||||
@@ -158,5 +161,4 @@ docker rmi -f $(docker images -aq)
|
||||
|
||||
### Documentation
|
||||
|
||||
The API docs are available at https://api.compassmeet.com. They are defined in [openapi.json](openapi.json).
|
||||
Just a few endpoints are mentioned in that JSON doc. Feel free to help by adding the remaining ones!
|
||||
The API doc is available at https://api.compassmeet.com. It's dynamically prepared in [app.ts](src/app.ts).
|
||||
|
||||
@@ -185,29 +185,29 @@ resource "google_compute_url_map" "api_url_map" {
|
||||
path_matcher {
|
||||
name = "allpaths"
|
||||
default_service = google_compute_backend_service.api_backend.self_link
|
||||
|
||||
# Priority 0: passthrough /v0/* requests
|
||||
route_rules {
|
||||
priority = 1
|
||||
match_rules {
|
||||
prefix_match = "/v0"
|
||||
}
|
||||
service = google_compute_backend_service.api_backend.self_link
|
||||
}
|
||||
|
||||
# Priority 1: rewrite everything else to /v0
|
||||
route_rules {
|
||||
priority = 2
|
||||
match_rules {
|
||||
prefix_match = "/"
|
||||
}
|
||||
route_action {
|
||||
url_rewrite {
|
||||
path_prefix_rewrite = "/v0/"
|
||||
}
|
||||
}
|
||||
service = google_compute_backend_service.api_backend.self_link
|
||||
}
|
||||
#
|
||||
# # Priority 0: passthrough /v0/* requests
|
||||
# route_rules {
|
||||
# priority = 1
|
||||
# match_rules {
|
||||
# prefix_match = "/v0"
|
||||
# }
|
||||
# service = google_compute_backend_service.api_backend.self_link
|
||||
# }
|
||||
#
|
||||
# # Priority 1: rewrite everything else to /v0
|
||||
# route_rules {
|
||||
# priority = 2
|
||||
# match_rules {
|
||||
# prefix_match = "/"
|
||||
# }
|
||||
# route_action {
|
||||
# url_rewrite { # This may break websockets (the Upgrade and Connection headers must pass through untouched).
|
||||
# path_prefix_rewrite = "/v0/"
|
||||
# }
|
||||
# }
|
||||
# service = google_compute_backend_service.api_backend.self_link
|
||||
# }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
"private": true,
|
||||
"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:serve": "nodemon -r tsconfig-paths/register --watch lib --ignore 'lib/**/*.map' src/serve.ts",
|
||||
"dev": "npx concurrently -n COMPILE,SERVER -c cyan,green \"yarn watch:compile\" \"yarn watch:serve\"",
|
||||
"dev": "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: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)",
|
||||
"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: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",
|
||||
"verify": "yarn --cwd=../.. verify",
|
||||
"verify:dir": "npx eslint . --max-warnings 0",
|
||||
"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": {
|
||||
"node": ">=20.0.0"
|
||||
@@ -44,29 +46,32 @@
|
||||
"colors": "1.4.0",
|
||||
"cors": "2.8.5",
|
||||
"dayjs": "1.11.4",
|
||||
"express": "4.18.1",
|
||||
"express": "5.0.0",
|
||||
"firebase-admin": "13.5.0",
|
||||
"gcp-metadata": "6.1.0",
|
||||
"jsonwebtoken": "9.0.0",
|
||||
"lodash": "4.17.21",
|
||||
"openapi-types": "12.1.3",
|
||||
"pg-promise": "11.4.1",
|
||||
"posthog-node": "4.11.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"resend": "4.1.2",
|
||||
"string-similarity": "4.0.4",
|
||||
"swagger-jsdoc": "6.2.8",
|
||||
"swagger-ui-express": "5.0.1",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"twitter-api-v2": "1.15.0",
|
||||
"ws": "8.17.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"zod": "3.21.4"
|
||||
"web-push": "3.6.7",
|
||||
"ws": "8.17.1",
|
||||
"zod": "3.22.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "2.8.17",
|
||||
"@types/react": "18.3.5",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"@types/swagger-ui-express": "4.1.8",
|
||||
"@types/web-push": "3.6.4",
|
||||
"@types/ws": "8.5.10"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {blockUser, unblockUser} from './block-user'
|
||||
import {getCompatibleProfilesHandler} from './compatible-profiles'
|
||||
import {createComment} from './create-comment'
|
||||
import {createCompatibilityQuestion} from './create-compatibility-question'
|
||||
import {setCompatibilityAnswer} from './set-compatibility-answer'
|
||||
import {createProfile} from './create-profile'
|
||||
import {createUser} from './create-user'
|
||||
import {getCompatibilityQuestions} from './get-compatibililty-questions'
|
||||
@@ -19,7 +20,6 @@ import {getLikesAndShips} from './get-likes-and-ships'
|
||||
import {getProfileAnswers} from './get-profile-answers'
|
||||
import {getProfiles} from './get-profiles'
|
||||
import {getSupabaseToken} from './get-supabase-token'
|
||||
import {getDisplayUser, getUser} from './get-user'
|
||||
import {getMe} from './get-me'
|
||||
import {hasFreeLike} from './has-free-like'
|
||||
import {health} from './health'
|
||||
@@ -40,7 +40,7 @@ import {getCurrentPrivateUser} from './get-current-private-user'
|
||||
import {createPrivateUserMessage} from './create-private-user-message'
|
||||
import {
|
||||
getChannelMemberships,
|
||||
getChannelMessages,
|
||||
getChannelMessagesEndpoint,
|
||||
getLastSeenChannelTime,
|
||||
setChannelLastSeenTime,
|
||||
} 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 {getNotifications} from './get-notifications'
|
||||
import {updateNotifSettings} from './update-notif-setting'
|
||||
import {setLastOnlineTime} from './set-last-online-time'
|
||||
import swaggerUi from "swagger-ui-express"
|
||||
import * as fs from "fs"
|
||||
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({})
|
||||
|
||||
function cacheController(policy?: string): RequestHandler {
|
||||
@@ -101,33 +119,200 @@ const apiErrorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
|
||||
export const app = express()
|
||||
app.use(requestMonitoring)
|
||||
|
||||
const swaggerDocument = JSON.parse(fs.readFileSync("./openapi.json", "utf-8"))
|
||||
swaggerDocument.info = {
|
||||
...swaggerDocument.info,
|
||||
description: "Compass is a free, open-source platform to help people form deep, meaningful, and lasting connections — whether platonic, romantic, or collaborative. It’s made possible by contributions from the community, including code, ideas, feedback, and donations. Unlike typical apps, Compass prioritizes values, interests, and personality over swipes and ads, giving you full control over who you discover and how you connect.",
|
||||
version: "1.0.0",
|
||||
contact: {
|
||||
name: "Compass",
|
||||
email: "compass.meet.info@gmail.com",
|
||||
url: "https://compassmeet.com"
|
||||
const schemaCache = new WeakMap<ZodTypeAny, any>();
|
||||
|
||||
export function zodToOpenApiSchema(
|
||||
zodObj: ZodTypeAny,
|
||||
nameHint?: string
|
||||
): any { // Prevent infinite recursion
|
||||
if (schemaCache.has(zodObj)) {
|
||||
return schemaCache.get(zodObj);
|
||||
}
|
||||
};
|
||||
|
||||
const def: any = (zodObj as any)._def;
|
||||
const typeName = def.typeName as ZodFirstPartyTypeKind;
|
||||
|
||||
// Placeholder so recursive references can point here
|
||||
const placeholder: any = {};
|
||||
schemaCache.set(zodObj, placeholder);
|
||||
|
||||
let schema: any;
|
||||
|
||||
switch (typeName) {
|
||||
case 'ZodString':
|
||||
schema = { type: 'string' };
|
||||
break;
|
||||
case 'ZodNumber':
|
||||
schema = { type: 'number' };
|
||||
break;
|
||||
case 'ZodBoolean':
|
||||
schema = { type: 'boolean' };
|
||||
break;
|
||||
case 'ZodEnum':
|
||||
schema = { type: 'string', enum: def.values };
|
||||
break;
|
||||
case 'ZodArray':
|
||||
schema = { type: 'array', items: zodToOpenApiSchema(def.type) };
|
||||
break;
|
||||
case 'ZodObject': {
|
||||
const shape = def.shape();
|
||||
const properties: Record<string, any> = {};
|
||||
const required: string[] = [];
|
||||
|
||||
for (const key in shape) {
|
||||
const child = shape[key];
|
||||
properties[key] = zodToOpenApiSchema(child, 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("/")
|
||||
app.get(rootPath, swaggerUi.setup(swaggerDocument))
|
||||
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> } = {
|
||||
health: health,
|
||||
'get-supabase-token': getSupabaseToken,
|
||||
'get-notifications': getNotifications,
|
||||
'mark-all-notifs-read': markAllNotifsRead,
|
||||
'user/:username': getUser,
|
||||
'user/:username/lite': getDisplayUser,
|
||||
// 'user/:username': getUser,
|
||||
// 'user/:username/lite': getDisplayUser,
|
||||
'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/unblock': unblockUser,
|
||||
'search-users': searchUsers,
|
||||
@@ -153,6 +338,10 @@ const handlers: { [k in APIPath]: APIHandler<k> } = {
|
||||
'create-comment': createComment,
|
||||
'hide-comment': hideComment,
|
||||
'create-compatibility-question': createCompatibilityQuestion,
|
||||
'set-compatibility-answer': setCompatibilityAnswer,
|
||||
'create-vote': createVote,
|
||||
'vote': vote,
|
||||
'contact': contact,
|
||||
'compatible-profiles': getCompatibleProfilesHandler,
|
||||
'search-location': searchLocation,
|
||||
'search-near-city': searchNearCity,
|
||||
@@ -161,9 +350,14 @@ const handlers: { [k in APIPath]: APIHandler<k> } = {
|
||||
'update-private-user-message-channel': updatePrivateUserMessageChannel,
|
||||
'leave-private-user-message-channel': leavePrivateUserMessageChannel,
|
||||
'get-channel-memberships': getChannelMemberships,
|
||||
'get-channel-messages': getChannelMessages,
|
||||
'get-channel-messages': getChannelMessagesEndpoint,
|
||||
'get-channel-seen-time': getLastSeenChannelTime,
|
||||
'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]) => {
|
||||
@@ -191,8 +385,6 @@ Object.entries(handlers).forEach(([path, handler]) => {
|
||||
}
|
||||
})
|
||||
|
||||
// console.log('COMPASS_API_KEY:', process.env.COMPASS_API_KEY)
|
||||
|
||||
// Internal Endpoints
|
||||
app.post(pathWithPrefix("/internal/send-search-notifications"),
|
||||
async (req, res) => {
|
||||
@@ -206,6 +398,10 @@ app.post(pathWithPrefix("/internal/send-search-notifications"),
|
||||
return res.status(200).json(result)
|
||||
} catch (err) {
|
||||
console.error("Failed to send notifications:", err);
|
||||
await sendDiscordMessage(
|
||||
"Failed to send [daily notifications](https://console.cloud.google.com/cloudscheduler?project=compass-130ba) for bookmarked searches...",
|
||||
"health"
|
||||
)
|
||||
return res.status(500).json({error: "Internal server error"});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { groupBy, sortBy } from 'lodash'
|
||||
import { APIError, type APIHandler } from 'api/helpers/endpoint'
|
||||
import { getCompatibilityScore } from 'common/love/compatibility-score'
|
||||
import { getCompatibilityScore } from 'common/profiles/compatibility-score'
|
||||
import {
|
||||
getProfile,
|
||||
getCompatibilityAnswers,
|
||||
getGenderCompatibleProfiles,
|
||||
} from 'shared/love/supabase'
|
||||
} from 'shared/profiles/supabase'
|
||||
import { log } from 'shared/utils'
|
||||
|
||||
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 { data, error } = await tryCatch(
|
||||
insert(pg, 'love_questions', {
|
||||
insert(pg, 'compatibility_prompts', {
|
||||
creator_id: creator.id,
|
||||
question,
|
||||
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 { uniq } from 'lodash'
|
||||
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'
|
||||
|
||||
export const createPrivateUserMessageChannel: APIHandler<
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import { APIError, APIHandler } from 'api/helpers/endpoint'
|
||||
import { getUser } from 'shared/utils'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { MAX_COMMENT_JSON_LENGTH } from 'api/create-comment'
|
||||
import { createPrivateUserMessageMain } from 'api/junk-drawer/private-messages'
|
||||
import {APIError, APIHandler} from 'api/helpers/endpoint'
|
||||
import {getUser} from 'shared/utils'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {MAX_COMMENT_JSON_LENGTH} from 'api/create-comment'
|
||||
import {createPrivateUserMessageMain} from 'api/helpers/private-messages'
|
||||
|
||||
export const createPrivateUserMessage: APIHandler<
|
||||
'create-private-user-message'
|
||||
> = async (body, auth) => {
|
||||
const { content, channelId } = body
|
||||
const {content, channelId} = body
|
||||
if (JSON.stringify(content).length > MAX_COMMENT_JSON_LENGTH) {
|
||||
throw new APIError(
|
||||
400,
|
||||
`Message JSON should be less than ${MAX_COMMENT_JSON_LENGTH}`
|
||||
)
|
||||
}
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const creator = await getUser(auth.uid)
|
||||
if (!creator) throw new APIError(401, 'Your account was not found')
|
||||
if (creator.isBannedFromPosting) throw new APIError(403, 'You are banned')
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
return await createPrivateUserMessageMain(
|
||||
creator,
|
||||
channelId,
|
||||
|
||||
@@ -2,12 +2,13 @@ import { APIError, APIHandler } from 'api/helpers/endpoint'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { log, getUser } from 'shared/utils'
|
||||
import { HOUR_MS } from 'common/util/time'
|
||||
import { removePinnedUrlFromPhotoUrls } from 'shared/love/parse-photos'
|
||||
import { removePinnedUrlFromPhotoUrls } from 'shared/profiles/parse-photos'
|
||||
import { track } from 'shared/analytics'
|
||||
import { updateUser } from 'shared/supabase/users'
|
||||
import { tryCatch } from 'common/util/try-catch'
|
||||
import { insert } from 'shared/supabase/utils'
|
||||
import {sendDiscordMessage} from "common/discord/core";
|
||||
import {jsonToMarkdown} from "common/md";
|
||||
|
||||
export const createProfile: APIHandler<'create-profile'> = async (body, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
@@ -29,7 +30,7 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
|
||||
updateUser(pg, auth.uid, { avatarUrl: body.pinned_url })
|
||||
}
|
||||
|
||||
console.log('body', body)
|
||||
console.debug('body', body)
|
||||
|
||||
const { data, error } = await tryCatch(
|
||||
insert(pg, 'profiles', { user_id: auth.uid, ...body })
|
||||
@@ -46,15 +47,17 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
|
||||
try {
|
||||
await track(auth.uid, 'create profile', {username: user.username})
|
||||
} catch (e) {
|
||||
console.log('Failed to track create profile', e)
|
||||
console.error('Failed to track create profile', e)
|
||||
}
|
||||
try {
|
||||
await sendDiscordMessage(
|
||||
`**${user.name}** just created a profile at https://www.compassmeet.com/${user.username}`,
|
||||
'members',
|
||||
)
|
||||
let message: string = `[**${user.name}**](https://www.compassmeet.com/${user.username}) just created a profile`
|
||||
if (body.bio) {
|
||||
const bioText = jsonToMarkdown(body.bio)
|
||||
if (bioText) message += `\n${bioText}`
|
||||
}
|
||||
await sendDiscordMessage(message, 'members')
|
||||
} catch (e) {
|
||||
console.log('Failed to send discord new profile', e)
|
||||
console.error('Failed to send discord new profile', e)
|
||||
}
|
||||
try {
|
||||
const nProfiles = await pg.one<number>(
|
||||
@@ -69,7 +72,7 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
|
||||
n % 50 === 0
|
||||
)
|
||||
}
|
||||
console.log(nProfiles, isMilestone(nProfiles))
|
||||
console.debug(nProfiles, isMilestone(nProfiles))
|
||||
if (isMilestone(nProfiles)) {
|
||||
await sendDiscordMessage(
|
||||
`We just reached **${nProfiles}** total profiles! 🎉`,
|
||||
@@ -78,7 +81,7 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
|
||||
}
|
||||
|
||||
} 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 {removeUndefinedProps} from 'common/util/object'
|
||||
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 {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {insert} from 'shared/supabase/utils'
|
||||
import {convertPrivateUser, convertUser} from 'common/supabase/users'
|
||||
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 (
|
||||
props,
|
||||
@@ -126,7 +128,17 @@ export const createUser: APIHandler<'create-user'> = async (
|
||||
try {
|
||||
await track(auth.uid, 'create profile', {username: user.username})
|
||||
} 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
|
||||
const pg = createSupabaseDirectClient()
|
||||
await pg.none('DELETE FROM users WHERE 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])
|
||||
// Should cascade delete in other tables
|
||||
// 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.)
|
||||
|
||||
// Delete user files from Firebase Storage
|
||||
@@ -35,7 +38,7 @@ export const deleteMe: APIHandler<'me/delete'> = async (body, auth) => {
|
||||
try {
|
||||
const auth = admin.auth()
|
||||
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) {
|
||||
console.error('Error deleting user from Firebase Auth:', e)
|
||||
}
|
||||
|
||||
@@ -16,30 +16,30 @@ export const getCompatibilityQuestions: APIHandler<
|
||||
> = async (_props, _auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const dbQuestions = await pg.manyOrNone<
|
||||
Row<'love_questions'> & { answer_count: number; score: number }
|
||||
const questions = await pg.manyOrNone<
|
||||
Row<'compatibility_prompts'> & { answer_count: number; score: number }
|
||||
>(
|
||||
`SELECT
|
||||
love_questions.*,
|
||||
COUNT(love_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
|
||||
compatibility_prompts.*,
|
||||
COUNT(compatibility_answers.question_id) as answer_count,
|
||||
AVG(POWER(compatibility_answers.importance + 1 + CASE WHEN compatibility_answers.explanation IS NULL THEN 1 ELSE 0 END, 2)) as score
|
||||
FROM
|
||||
love_questions
|
||||
compatibility_prompts
|
||||
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'
|
||||
compatibility_prompts.answer_type = 'compatibility_multiple_choice'
|
||||
GROUP BY
|
||||
love_questions.id
|
||||
ORDER BY
|
||||
score DESC
|
||||
compatibility_prompts.id
|
||||
ORDER BY
|
||||
compatibility_prompts.importance_score
|
||||
`,
|
||||
[]
|
||||
)
|
||||
|
||||
const questions = shuffle(dbQuestions)
|
||||
// const questions = shuffle(dbQuestions)
|
||||
|
||||
// console.log(
|
||||
// console.debug(
|
||||
// 'got questions',
|
||||
// questions.map((q) => q.question + ' ' + q.score)
|
||||
// )
|
||||
|
||||
@@ -20,10 +20,10 @@ export const getLikesAndShipsMain = async (userId: string) => {
|
||||
created_time: number
|
||||
}>(
|
||||
`
|
||||
select target_id, love_likes.created_time
|
||||
from love_likes
|
||||
join profiles on profiles.user_id = love_likes.target_id
|
||||
join users on users.id = love_likes.target_id
|
||||
select target_id, profile_likes.created_time
|
||||
from profile_likes
|
||||
join profiles on profiles.user_id = profile_likes.target_id
|
||||
join users on users.id = profile_likes.target_id
|
||||
where creator_id = $1
|
||||
and looking_for_matches
|
||||
and profiles.pinned_url is not null
|
||||
@@ -42,10 +42,10 @@ export const getLikesAndShipsMain = async (userId: string) => {
|
||||
created_time: number
|
||||
}>(
|
||||
`
|
||||
select creator_id, love_likes.created_time
|
||||
from love_likes
|
||||
join profiles on profiles.user_id = love_likes.creator_id
|
||||
join users on users.id = love_likes.creator_id
|
||||
select creator_id, profile_likes.created_time
|
||||
from profile_likes
|
||||
join profiles on profiles.user_id = profile_likes.creator_id
|
||||
join users on users.id = profile_likes.creator_id
|
||||
where target_id = $1
|
||||
and looking_for_matches
|
||||
and profiles.pinned_url is not null
|
||||
@@ -68,11 +68,11 @@ export const getLikesAndShipsMain = async (userId: string) => {
|
||||
}>(
|
||||
`
|
||||
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
|
||||
from love_ships
|
||||
join profiles on profiles.user_id = love_ships.target1_id
|
||||
join users on users.id = love_ships.target1_id
|
||||
from profile_ships
|
||||
join profiles on profiles.user_id = profile_ships.target1_id
|
||||
join users on users.id = profile_ships.target1_id
|
||||
where target2_id = $1
|
||||
and profiles.looking_for_matches
|
||||
and profiles.pinned_url is not null
|
||||
@@ -81,11 +81,11 @@ export const getLikesAndShipsMain = async (userId: string) => {
|
||||
union all
|
||||
|
||||
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
|
||||
from love_ships
|
||||
join profiles on profiles.user_id = love_ships.target2_id
|
||||
join users on users.id = love_ships.target2_id
|
||||
from profile_ships
|
||||
join profiles on profiles.user_id = profile_ships.target2_id
|
||||
join users on users.id = profile_ships.target2_id
|
||||
where target1_id = $1
|
||||
and profiles.looking_for_matches
|
||||
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 { APIHandler } from './helpers/endpoint'
|
||||
import {
|
||||
convertPrivateChatMessage,
|
||||
PrivateMessageChannel,
|
||||
} from 'common/supabase/private-messages'
|
||||
import { groupBy, mapValues } from 'lodash'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {PrivateMessageChannel,} from 'common/supabase/private-messages'
|
||||
import {groupBy, mapValues} from 'lodash'
|
||||
import {convertPrivateChatMessage} from "shared/supabase/messages";
|
||||
import {tryCatch} from "common/util/try-catch";
|
||||
|
||||
export const getChannelMemberships: APIHandler<
|
||||
'get-channel-memberships'
|
||||
> = async (props, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
const { channelId, lastUpdatedTime, createdTime, limit } = props
|
||||
const {channelId, lastUpdatedTime, createdTime, limit} = props
|
||||
|
||||
let channels: PrivateMessageChannel[]
|
||||
const convertRow = (r: any) => ({
|
||||
@@ -24,55 +23,56 @@ export const getChannelMemberships: APIHandler<
|
||||
channels = await pg.map(
|
||||
`select channel_id, notify_after_time, pumcm.created_time, last_updated_time
|
||||
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
|
||||
and channel_id = $2
|
||||
and channel_id = $2
|
||||
limit $3
|
||||
`,
|
||||
`,
|
||||
[auth.uid, channelId, limit],
|
||||
convertRow
|
||||
)
|
||||
} else {
|
||||
channels = await pg.map(
|
||||
`with latest_channels as (
|
||||
select distinct on (pumc.id) pumc.id as channel_id, notify_after_time, pumc.created_time,
|
||||
(select created_time
|
||||
from private_user_messages
|
||||
where channel_id = pumc.id
|
||||
and visibility != 'system_status'
|
||||
and user_id != $1
|
||||
order by created_time desc
|
||||
limit 1) as last_updated_time, -- last_updated_time is the last possible unseen message time
|
||||
pumc.last_updated_time as last_updated_channel_time -- last_updated_channel_time is the last time the channel was updated
|
||||
from private_user_message_channels pumc
|
||||
join private_user_message_channel_members pumcm on pumcm.channel_id = pumc.id
|
||||
inner join private_user_messages pum on pumc.id = pum.channel_id
|
||||
and (pum.visibility != 'introduction' or pum.user_id != $1)
|
||||
where pumcm.user_id = $1
|
||||
and not status = 'left'
|
||||
and ($2 is null or pumcm.created_time > $2)
|
||||
and ($4 is null or pumc.last_updated_time > $4)
|
||||
order by pumc.id, pumc.last_updated_time desc
|
||||
)
|
||||
select * from latest_channels
|
||||
`with latest_channels as (select distinct on (pumc.id) pumc.id as channel_id,
|
||||
notify_after_time,
|
||||
pumc.created_time,
|
||||
(select created_time
|
||||
from private_user_messages
|
||||
where channel_id = pumc.id
|
||||
and visibility != 'system_status'
|
||||
and user_id != $1
|
||||
order by created_time desc
|
||||
limit 1) as last_updated_time, -- last_updated_time is the last possible unseen message time
|
||||
pumc.last_updated_time as last_updated_channel_time -- last_updated_channel_time is the last time the channel was updated
|
||||
from private_user_message_channels pumc
|
||||
join private_user_message_channel_members pumcm on pumcm.channel_id = pumc.id
|
||||
inner join private_user_messages pum on pumc.id = pum.channel_id
|
||||
and (pum.visibility != 'introduction' or pum.user_id != $1)
|
||||
where pumcm.user_id = $1
|
||||
and not status = 'left'
|
||||
and ($2 is null or pumcm.created_time > $2)
|
||||
and ($4 is null or pumc.last_updated_time > $4)
|
||||
order by pumc.id, pumc.last_updated_time desc)
|
||||
select *
|
||||
from latest_channels
|
||||
order by last_updated_channel_time desc
|
||||
limit $3
|
||||
`,
|
||||
`,
|
||||
[auth.uid, createdTime ?? null, limit, lastUpdatedTime ?? null],
|
||||
convertRow
|
||||
)
|
||||
}
|
||||
if (!channels || channels.length === 0)
|
||||
return { channels: [], memberIdsByChannelId: {} }
|
||||
return {channels: [], memberIdsByChannelId: {}}
|
||||
const channelIds = channels.map((c) => c.channel_id)
|
||||
|
||||
const members = await pg.map(
|
||||
`select channel_id, user_id
|
||||
from private_user_message_channel_members
|
||||
where not user_id = $1
|
||||
and channel_id in ($2:list)
|
||||
and not status = 'left'
|
||||
`,
|
||||
and channel_id in ($2:list)
|
||||
and not status = 'left'
|
||||
`,
|
||||
[auth.uid, channelIds],
|
||||
(r) => ({
|
||||
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,
|
||||
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 { channelId, limit, id } = props
|
||||
return await pg.map(
|
||||
const {data, error} = await tryCatch(pg.map(
|
||||
`select *, created_time as created_time_ts
|
||||
from private_user_messages
|
||||
where channel_id = $1
|
||||
and exists (select 1 from private_user_message_channel_members pumcm
|
||||
where pumcm.user_id = $2
|
||||
and pumcm.channel_id = $1
|
||||
)
|
||||
from private_user_messages
|
||||
where channel_id = $1
|
||||
and exists (select 1
|
||||
from private_user_message_channel_members pumcm
|
||||
where pumcm.user_id = $2
|
||||
and pumcm.channel_id = $1)
|
||||
and ($4 is null or id > $4)
|
||||
and not visibility = 'system_status'
|
||||
order by created_time desc
|
||||
limit $3
|
||||
`,
|
||||
[channelId, auth.uid, limit, id],
|
||||
and not visibility = 'system_status'
|
||||
order by created_time desc
|
||||
limit $3
|
||||
`,
|
||||
[channelId, userId, limit, id],
|
||||
convertPrivateChatMessage
|
||||
)
|
||||
))
|
||||
if (error) {
|
||||
console.error(error)
|
||||
throw new APIError(401, 'Error getting messages')
|
||||
}
|
||||
// console.log('final messages', data)
|
||||
return data
|
||||
}
|
||||
|
||||
export const getLastSeenChannelTime: APIHandler<
|
||||
'get-channel-seen-time'
|
||||
> = async (props, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
const { channelIds } = props
|
||||
const {channelIds} = props
|
||||
const unseens = await pg.map(
|
||||
`select distinct on (channel_id) channel_id, created_time
|
||||
from private_user_seen_message_channels
|
||||
where channel_id = any($1)
|
||||
where channel_id = any ($1)
|
||||
and user_id = $2
|
||||
order by channel_id, created_time desc
|
||||
`,
|
||||
@@ -137,11 +154,11 @@ export const setChannelLastSeenTime: APIHandler<
|
||||
'set-channel-seen-time'
|
||||
> = async (props, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
const { channelId } = props
|
||||
const {channelId} = props
|
||||
await pg.none(
|
||||
`insert into private_user_seen_message_channels (user_id, channel_id)
|
||||
values ($1, $2)
|
||||
`,
|
||||
`insert into private_user_seen_message_channels (user_id, channel_id)
|
||||
values ($1, $2)
|
||||
`,
|
||||
[auth.uid, channelId]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ export const getProfileAnswers: APIHandler<'get-profile-answers'> = async (
|
||||
const { userId } = props
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const answers = await pg.manyOrNone<Row<'love_compatibility_answers'>>(
|
||||
`select * from love_compatibility_answers
|
||||
const answers = await pg.manyOrNone<Row<'compatibility_answers'>>(
|
||||
`select * from compatibility_answers
|
||||
where
|
||||
creator_id = $1
|
||||
order by created_time desc
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
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 {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 {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 = {
|
||||
limit?: number | undefined,
|
||||
@@ -12,43 +12,67 @@ export type profileQueryType = {
|
||||
// Search and filter parameters
|
||||
name?: string | undefined,
|
||||
genders?: String[] | undefined,
|
||||
education_levels?: String[] | undefined,
|
||||
pref_gender?: String[] | undefined,
|
||||
pref_age_min?: number | undefined,
|
||||
pref_age_max?: number | undefined,
|
||||
drinks_min?: number | undefined,
|
||||
drinks_max?: number | undefined,
|
||||
pref_relation_styles?: String[] | undefined,
|
||||
pref_romantic_styles?: String[] | undefined,
|
||||
diet?: String[] | undefined,
|
||||
political_beliefs?: String[] | undefined,
|
||||
wants_kids_strength?: number | undefined,
|
||||
has_kids?: number | undefined,
|
||||
is_smoker?: boolean | undefined,
|
||||
shortBio?: boolean | undefined,
|
||||
geodbCityIds?: String[] | undefined,
|
||||
lat?: number | undefined,
|
||||
lon?: number | undefined,
|
||||
radius?: number | undefined,
|
||||
compatibleWithUserId?: string | undefined,
|
||||
skipId?: string | undefined,
|
||||
orderBy?: string | undefined,
|
||||
lastModificationWithin?: string | undefined,
|
||||
}
|
||||
|
||||
const userActivityColumns = ['last_online_time']
|
||||
|
||||
|
||||
export const loadProfiles = async (props: profileQueryType) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
console.log(props)
|
||||
console.debug(props)
|
||||
const {
|
||||
limit: limitParam,
|
||||
after,
|
||||
name,
|
||||
genders,
|
||||
education_levels,
|
||||
pref_gender,
|
||||
pref_age_min,
|
||||
pref_age_max,
|
||||
drinks_min,
|
||||
drinks_max,
|
||||
pref_relation_styles,
|
||||
pref_romantic_styles,
|
||||
diet,
|
||||
political_beliefs,
|
||||
wants_kids_strength,
|
||||
has_kids,
|
||||
is_smoker,
|
||||
shortBio,
|
||||
geodbCityIds,
|
||||
lat,
|
||||
lon,
|
||||
radius,
|
||||
compatibleWithUserId,
|
||||
orderBy: orderByParam = 'created_time',
|
||||
lastModificationWithin,
|
||||
skipId,
|
||||
} = props
|
||||
|
||||
const filterLocation = lat && lon && radius
|
||||
|
||||
const keywords = name ? name.split(",").map(q => q.trim()).filter(Boolean) : []
|
||||
// console.debug('keywords:', keywords)
|
||||
|
||||
@@ -64,13 +88,24 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
(l) =>
|
||||
(!name || l.user.name.toLowerCase().includes(name.toLowerCase())) &&
|
||||
(!genders || genders.includes(l.gender)) &&
|
||||
(!education_levels || education_levels.includes(l.education_level ?? '')) &&
|
||||
(!pref_gender || intersection(pref_gender, l.pref_gender).length) &&
|
||||
(!pref_age_min || (l.age ?? MAX_INT) >= pref_age_min) &&
|
||||
(!pref_age_max || (l.age ?? MIN_INT) <= pref_age_max) &&
|
||||
(!drinks_min || (l.drinks_per_month ?? MAX_INT) >= drinks_min) &&
|
||||
(!drinks_max || (l.drinks_per_month ?? MIN_INT) <= drinks_max) &&
|
||||
(!pref_relation_styles ||
|
||||
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 == -1 ||
|
||||
!l.wants_kids_strength ||
|
||||
l.wants_kids_strength == -1 ||
|
||||
(wants_kids_strength >= 2
|
||||
? 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 == 0 && !l.has_kids) ||
|
||||
(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) &&
|
||||
(!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
|
||||
? profiles.findIndex((l) => l.id.toString() === after) + 1
|
||||
: 0
|
||||
console.log(cursor)
|
||||
console.debug(cursor)
|
||||
|
||||
if (limitParam) return profiles.slice(cursor, cursor + limitParam)
|
||||
|
||||
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(
|
||||
select('profiles.*, name, username, users.data as user'),
|
||||
select('profiles.*, name, username, users.data as user, user_activity.last_online_time'),
|
||||
from('profiles'),
|
||||
join('users on users.id = profiles.user_id'),
|
||||
leftJoin(userActivityJoin),
|
||||
where('looking_for_matches = true'),
|
||||
// where(`pinned_url is not null and pinned_url != ''`),
|
||||
where(
|
||||
@@ -106,14 +152,16 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
where(`data->>'userDeleted' != 'true' or data->>'userDeleted' is null`),
|
||||
|
||||
...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}
|
||||
)),
|
||||
|
||||
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 &&
|
||||
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 &&
|
||||
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 &&
|
||||
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 &&
|
||||
where(
|
||||
`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 !== -1 &&
|
||||
where(
|
||||
wants_kids_strength >= 2
|
||||
? `wants_kids_strength >= $(wants_kids_strength)`
|
||||
: `wants_kids_strength <= $(wants_kids_strength)`,
|
||||
'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}
|
||||
),
|
||||
|
||||
has_kids === 0 && where(`has_kids IS NULL OR 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 &&
|
||||
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 &&
|
||||
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}
|
||||
),
|
||||
|
||||
!shortBio && where(`bio_length >= ${MIN_BIO_LENGTH}`, {MIN_BIO_LENGTH}),
|
||||
|
||||
lastModificationWithin && where(`last_modification_time >= NOW() - INTERVAL $(lastModificationWithin)`, {lastModificationWithin}),
|
||||
|
||||
limitParam && limit(limitParam)
|
||||
)
|
||||
|
||||
// console.log('query:', query)
|
||||
// console.debug('query:', query)
|
||||
|
||||
return await pg.map(query, [], convertRow)
|
||||
}
|
||||
|
||||
@@ -17,17 +17,18 @@ export const getUser = async (props: { id: string } | { username: string }) => {
|
||||
return toUserAPIResponse(user)
|
||||
}
|
||||
|
||||
export const getDisplayUser = async (
|
||||
props: { id: string } | { username: string }
|
||||
) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
const liteUser = await pg.oneOrNone(
|
||||
`select ${displayUserColumns}
|
||||
from users
|
||||
where ${'id' in props ? 'id' : 'username'} = $1`,
|
||||
['id' in props ? props.id : props.username]
|
||||
)
|
||||
if (!liteUser) throw new APIError(404, 'User not found')
|
||||
|
||||
return removeNullOrUndefinedProps(liteUser)
|
||||
}
|
||||
// export const getDisplayUser = async (
|
||||
// props: { id: string } | { username: string }
|
||||
// ) => {
|
||||
// console.log('getDisplayUser', props)
|
||||
// const pg = createSupabaseDirectClient()
|
||||
// const liteUser = await pg.oneOrNone(
|
||||
// `select ${displayUserColumns}
|
||||
// from users
|
||||
// where ${'id' in props ? 'id' : 'username'} = $1`,
|
||||
// ['id' in props ? props.id : props.username]
|
||||
// )
|
||||
// if (!liteUser) throw new APIError(404, 'User not found')
|
||||
//
|
||||
// return removeNullOrUndefinedProps(liteUser)
|
||||
// }
|
||||
|
||||
@@ -17,7 +17,7 @@ export const getHasFreeLike = async (userId: string) => {
|
||||
const likeGivenToday = await pg.oneOrNone<object>(
|
||||
`
|
||||
select 1
|
||||
from love_likes
|
||||
from profile_likes
|
||||
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 + interval '1 day')
|
||||
|
||||
@@ -174,11 +174,63 @@ export type APIHandler<N extends APIPath> = (
|
||||
req: Request
|
||||
) => 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>(
|
||||
name: 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) => {
|
||||
let authUser: AuthedUser | undefined = undefined
|
||||
@@ -188,6 +240,15 @@ export const typedEndpoint = <N extends APIPath>(
|
||||
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 = {
|
||||
...(method === 'GET' ? req.query : req.body),
|
||||
...req.params,
|
||||
@@ -211,7 +272,7 @@ export const typedEndpoint = <N extends APIPath>(
|
||||
if (!res.headersSent) {
|
||||
// Convert bigint to number, b/c JSON doesn't support bigint.
|
||||
const convertedResult = deepConvertBigIntToNumber(result)
|
||||
|
||||
// console.debug('API result', convertedResult)
|
||||
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 {
|
||||
insertPrivateMessage,
|
||||
leaveChatContent,
|
||||
} from 'api/junk-drawer/private-messages'
|
||||
} from 'api/helpers/private-messages'
|
||||
|
||||
export const leavePrivateUserMessageChannel: APIHandler<
|
||||
'leave-private-user-message-channel'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
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 { log } from 'shared/utils'
|
||||
import { tryCatch } from 'common/util/try-catch'
|
||||
@@ -15,7 +15,7 @@ export const likeProfile: APIHandler<'like-profile'> = async (props, auth) => {
|
||||
if (remove) {
|
||||
const { error } = await tryCatch(
|
||||
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]
|
||||
)
|
||||
)
|
||||
@@ -28,8 +28,8 @@ export const likeProfile: APIHandler<'like-profile'> = async (props, auth) => {
|
||||
|
||||
// Check if like already exists
|
||||
const { data: existing } = await tryCatch(
|
||||
pg.oneOrNone<Row<'love_likes'>>(
|
||||
'select * from love_likes where creator_id = $1 and target_id = $2',
|
||||
pg.oneOrNone<Row<'profile_likes'>>(
|
||||
'select * from profile_likes where creator_id = $1 and target_id = $2',
|
||||
[creatorId, targetUserId]
|
||||
)
|
||||
)
|
||||
@@ -48,8 +48,8 @@ export const likeProfile: APIHandler<'like-profile'> = async (props, auth) => {
|
||||
|
||||
// Insert the new like
|
||||
const { data, error } = await tryCatch(
|
||||
pg.one<Row<'love_likes'>>(
|
||||
'insert into love_likes (creator_id, target_id) values ($1, $2) returning *',
|
||||
pg.one<Row<'profile_likes'>>(
|
||||
'insert into profile_likes (creator_id, target_id) values ($1, $2) returning *',
|
||||
[creatorId, targetUserId]
|
||||
)
|
||||
)
|
||||
@@ -59,7 +59,7 @@ export const likeProfile: APIHandler<'like-profile'> = async (props, auth) => {
|
||||
}
|
||||
|
||||
const continuation = async () => {
|
||||
await createLoveLikeNotification(data)
|
||||
await createProfileLikeNotification(data)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { APIError, APIHandler } from './helpers/endpoint'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { tryCatch } from 'common/util/try-catch'
|
||||
import { insert } from 'shared/supabase/utils'
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {tryCatch} from 'common/util/try-catch'
|
||||
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
|
||||
// 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)
|
||||
}
|
||||
|
||||
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) => {
|
||||
const {term, limit} = body
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -2,13 +2,12 @@ import {APIHandler} from './helpers/endpoint'
|
||||
import {geodbFetch} from "common/geodb";
|
||||
|
||||
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=10`
|
||||
const endpoint = `/cities/${cityId}/nearbyCities?radius=${radius}&offset=0&sort=-population&limit=100`
|
||||
return await geodbFetch(endpoint)
|
||||
}
|
||||
|
||||
export const searchNearCity: APIHandler<'search-near-city'> = async (body) => {
|
||||
const { cityId, radius } = body
|
||||
const {cityId, radius} = body
|
||||
return await searchNearCityMain(cityId, radius)
|
||||
}
|
||||
|
||||
|
||||
@@ -20,15 +20,15 @@ export const searchUsers: APIHandler<'search-users'> = async (props, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const offset = page * limit
|
||||
const userId = auth?.uid
|
||||
const searchFollowersSQL = getSearchUserSQL({ term, offset, limit, userId })
|
||||
// const userId = auth?.uid
|
||||
// const searchFollowersSQL = getSearchUserSQL({ term, offset, limit, userId })
|
||||
const searchAllSQL = getSearchUserSQL({ term, offset, limit })
|
||||
const [followers, all] = await Promise.all([
|
||||
pg.map(searchFollowersSQL, null, convertUser),
|
||||
const [all] = await Promise.all([
|
||||
// pg.map(searchFollowersSQL, null, convertUser),
|
||||
pg.map(searchAllSQL, null, convertUser),
|
||||
])
|
||||
|
||||
return uniqBy([...followers, ...all], 'id')
|
||||
return uniqBy([...all], 'id')
|
||||
.map(toUserAPIResponse)
|
||||
.slice(0, limit)
|
||||
}
|
||||
@@ -39,17 +39,18 @@ function getSearchUserSQL(props: {
|
||||
limit: number
|
||||
userId?: string // search only this user's followers
|
||||
}) {
|
||||
const { term, userId } = props
|
||||
const { term } = props
|
||||
|
||||
return renderSql(
|
||||
userId
|
||||
? [
|
||||
select('users.*'),
|
||||
from('users'),
|
||||
join('user_follows on user_follows.follow_id = users.id'),
|
||||
where('user_follows.user_id = $1', [userId]),
|
||||
]
|
||||
: [select('*'), from('users')],
|
||||
// userId
|
||||
// ? [
|
||||
// select('users.*'),
|
||||
// from('users'),
|
||||
// join('user_follows on user_follows.follow_id = users.id'),
|
||||
// where('user_follows.user_id = $1', [userId]),
|
||||
// ]
|
||||
// :
|
||||
[select('*'), from('users')],
|
||||
term
|
||||
? [
|
||||
where(
|
||||
|
||||
@@ -3,7 +3,7 @@ import {from, renderSql, select} from "shared/supabase/sql-builder";
|
||||
import {loadProfiles, profileQueryType} from "api/get-profiles";
|
||||
import {Row} from "common/supabase/utils";
|
||||
import {sendSearchAlertsEmail} from "email/functions/helpers";
|
||||
import {MatchesByUserType} from "common/love/bookmarked_searches";
|
||||
import {MatchesByUserType} from "common/profiles/bookmarked_searches";
|
||||
import {keyBy} from "lodash";
|
||||
|
||||
export function convertSearchRow(row: any): any {
|
||||
@@ -25,7 +25,7 @@ export const sendSearchNotifications = async () => {
|
||||
from('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(
|
||||
renderSql(
|
||||
@@ -36,7 +36,7 @@ export const sendSearchNotifications = async () => {
|
||||
convertSearchRow
|
||||
) as Row<'users'>[]
|
||||
const users = keyBy(_users, 'id')
|
||||
console.log('users', users)
|
||||
console.debug('users', users)
|
||||
|
||||
const _privateUsers = await pg.map(
|
||||
renderSql(
|
||||
@@ -47,15 +47,21 @@ export const sendSearchNotifications = async () => {
|
||||
convertSearchRow
|
||||
) as Row<'private_users'>[]
|
||||
const privateUsers = keyBy(_privateUsers, 'id')
|
||||
console.log('privateUsers', privateUsers)
|
||||
console.debug('privateUsers', privateUsers)
|
||||
|
||||
const matches: MatchesByUserType = {}
|
||||
|
||||
for (const row of searches) {
|
||||
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)
|
||||
console.log(profiles.map((item: any) => item.name))
|
||||
console.debug(profiles.map((item: any) => item.name))
|
||||
if (!profiles.length) continue
|
||||
if (!(row.creator_id in matches)) {
|
||||
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)
|
||||
|
||||
return {status: 'success'}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import "tsconfig-paths/register";
|
||||
import * as admin from 'firebase-admin'
|
||||
import {initAdmin} from 'shared/init-admin'
|
||||
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 { 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 { tryCatch } from 'common/util/try-catch'
|
||||
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
|
||||
const existing = await tryCatch(
|
||||
pg.oneOrNone<{ ship_id: string }>(
|
||||
`select ship_id from love_ships
|
||||
`select ship_id from profile_ships
|
||||
where creator_id = $1
|
||||
and (
|
||||
target1_id = $2 and target2_id = $3
|
||||
@@ -33,7 +33,7 @@ export const shipProfiles: APIHandler<'ship-profiles'> = async (props, auth) =>
|
||||
if (existing.data) {
|
||||
if (remove) {
|
||||
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,
|
||||
])
|
||||
)
|
||||
@@ -48,7 +48,7 @@ export const shipProfiles: APIHandler<'ship-profiles'> = async (props, auth) =>
|
||||
|
||||
// Insert the new ship
|
||||
const { data, error } = await tryCatch(
|
||||
insert(pg, 'love_ships', {
|
||||
insert(pg, 'profile_ships', {
|
||||
creator_id: creatorId,
|
||||
target1_id: targetUserId1,
|
||||
target2_id: targetUserId2,
|
||||
@@ -61,8 +61,8 @@ export const shipProfiles: APIHandler<'ship-profiles'> = async (props, auth) =>
|
||||
|
||||
const continuation = async () => {
|
||||
await Promise.all([
|
||||
createLoveShipNotification(data, data.target1_id),
|
||||
createLoveShipNotification(data, data.target2_id),
|
||||
createProfileShipNotification(data, data.target1_id),
|
||||
createProfileShipNotification(data, data.target2_id),
|
||||
])
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export const starProfile: APIHandler<'star-profile'> = async (props, auth) => {
|
||||
if (remove) {
|
||||
const { error } = await tryCatch(
|
||||
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]
|
||||
)
|
||||
)
|
||||
@@ -27,8 +27,8 @@ export const starProfile: APIHandler<'star-profile'> = async (props, auth) => {
|
||||
|
||||
// Check if star already exists
|
||||
const { data: existing } = await tryCatch(
|
||||
pg.oneOrNone<Row<'love_stars'>>(
|
||||
'select * from love_stars where creator_id = $1 and target_id = $2',
|
||||
pg.oneOrNone<Row<'profile_stars'>>(
|
||||
'select * from profile_stars where creator_id = $1 and target_id = $2',
|
||||
[creatorId, targetUserId]
|
||||
)
|
||||
)
|
||||
@@ -40,7 +40,7 @@ export const starProfile: APIHandler<'star-profile'> = async (props, auth) => {
|
||||
|
||||
// Insert the new star
|
||||
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) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { updateUser } from 'shared/supabase/users'
|
||||
import { log } from 'shared/utils'
|
||||
@@ -24,8 +24,7 @@ export const updateProfile: APIHandler<'update-profile'> = async (
|
||||
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)
|
||||
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_GROUP="${SERVICE_NAME}-group"
|
||||
ZONE="us-west1-c"
|
||||
ENV=${1:-dev}
|
||||
#ENV=${1:-dev}
|
||||
ENV=prod
|
||||
|
||||
case $ENV in
|
||||
dev)
|
||||
@@ -28,10 +29,19 @@ INSTANCE_ID=$(gcloud compute instances list \
|
||||
--format="value(name)" \
|
||||
--limit=1)
|
||||
|
||||
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} \
|
||||
--project=${GCLOUD_PROJECT} \
|
||||
--zone=${ZONE} \
|
||||
#echo "Forwarding debugging port 9229 to ${INSTANCE_ID}. Open chrome://inspect in Chrome to connect."
|
||||
|
||||
if [ "$1" = "logs" ]; then
|
||||
CMD=(--command="sudo docker logs -f \$(sudo docker ps -alq)")
|
||||
else
|
||||
CMD=()
|
||||
fi
|
||||
|
||||
gcloud compute ssh "${INSTANCE_ID}" \
|
||||
--project="${GCLOUD_PROJECT}" \
|
||||
--zone="${ZONE}" \
|
||||
"${CMD[@]}"
|
||||
|
||||
# -- \
|
||||
# -NL 9229:localhost:9229
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"tsBuildInfoFile": "lib/tsconfig.tsbuildinfo",
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"resolveJsonModule": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "esnext",
|
||||
@@ -50,6 +51,7 @@
|
||||
"compileOnSave": true,
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"openapi.json"
|
||||
"package.json",
|
||||
"backend/api/package.json"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
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 {NewMessageEmail} from '../new-message'
|
||||
import {NewEndorsementEmail} from '../new-endorsement'
|
||||
import {Test} from '../test'
|
||||
import {getProfile} from 'shared/love/supabase'
|
||||
import {getProfile} from 'shared/profiles/supabase'
|
||||
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 WelcomeEmail from "email/welcome";
|
||||
|
||||
const from = 'Compass <no-reply@compassmeet.com>'
|
||||
const from = 'Compass <compass@compassmeet.com>'
|
||||
|
||||
// export const sendNewMatchEmail = async (
|
||||
// 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 (
|
||||
toUser: User,
|
||||
privateUser: PrivateUser,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ProfileRow } from 'common/love/profile'
|
||||
import type { User } from 'common/user'
|
||||
import {ProfileRow} from 'common/profiles/profile'
|
||||
import type {User} from 'common/user'
|
||||
|
||||
// for email template testing
|
||||
|
||||
export const sinclairUser: User = {
|
||||
export const mockUser: User = {
|
||||
createdTime: 0,
|
||||
bio: 'the futa in futarchy',
|
||||
website: 'sincl.ai',
|
||||
@@ -17,7 +17,6 @@ export const sinclairUser: User = {
|
||||
id: '0k1suGSJKVUnHbCPEhHNpgZPkUP2',
|
||||
username: 'Sinclair',
|
||||
name: 'Sinclair',
|
||||
// url: 'https://manifold.love/Sinclair',
|
||||
// isAdmin: true,
|
||||
// isTrustworthy: false,
|
||||
link: {
|
||||
@@ -31,14 +30,14 @@ export const sinclairProfile: ProfileRow = {
|
||||
id: 55,
|
||||
user_id: '0k1suGSJKVUnHbCPEhHNpgZPkUP2',
|
||||
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',
|
||||
city: 'San Francisco',
|
||||
gender: 'trans-female',
|
||||
pref_gender: ['female', 'trans-female'],
|
||||
pref_age_min: 18,
|
||||
pref_age_max: 21,
|
||||
pref_relation_styles: ['poly', 'open', 'mono'],
|
||||
pref_relation_styles: ['friendship'],
|
||||
pref_romantic_styles: ['poly', 'open', 'mono'],
|
||||
wants_kids_strength: 3,
|
||||
looking_for_matches: true,
|
||||
visibility: 'public',
|
||||
@@ -47,7 +46,7 @@ export const sinclairProfile: ProfileRow = {
|
||||
has_kids: 0,
|
||||
is_smoker: false,
|
||||
drinks_per_month: 0,
|
||||
is_vegetarian_or_vegan: null,
|
||||
diet: null,
|
||||
political_beliefs: ['e/acc', 'libertarian'],
|
||||
religious_belief_strength: null,
|
||||
religious_beliefs: null,
|
||||
@@ -78,6 +77,7 @@ export const sinclairProfile: ProfileRow = {
|
||||
city_longitude: -122.416389,
|
||||
geodb_city_id: '126964',
|
||||
referred_by_username: null,
|
||||
bio_length: 1000,
|
||||
bio: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
@@ -101,6 +101,8 @@ export const sinclairProfile: ProfileRow = {
|
||||
},
|
||||
],
|
||||
},
|
||||
bio_text: 'the futa in futarchy',
|
||||
bio_tsv: 'the futa in futarchy',
|
||||
age: 25,
|
||||
}
|
||||
|
||||
@@ -129,14 +131,14 @@ export const jamesProfile: ProfileRow = {
|
||||
id: 2,
|
||||
user_id: '5LZ4LgYuySdL1huCWe7bti02ghx2',
|
||||
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',
|
||||
city: 'San Francisco',
|
||||
gender: 'male',
|
||||
pref_gender: ['female'],
|
||||
pref_age_min: 22,
|
||||
pref_age_max: 32,
|
||||
pref_relation_styles: ['mono'],
|
||||
pref_relation_styles: ['friendship'],
|
||||
pref_romantic_styles: ['poly', 'open', 'mono'],
|
||||
wants_kids_strength: 4,
|
||||
looking_for_matches: true,
|
||||
visibility: 'public',
|
||||
@@ -145,7 +147,7 @@ export const jamesProfile: ProfileRow = {
|
||||
has_kids: 0,
|
||||
is_smoker: false,
|
||||
drinks_per_month: 5,
|
||||
is_vegetarian_or_vegan: null,
|
||||
diet: null,
|
||||
political_beliefs: ['libertarian'],
|
||||
religious_belief_strength: null,
|
||||
religious_beliefs: '',
|
||||
@@ -173,6 +175,7 @@ export const jamesProfile: ProfileRow = {
|
||||
city_longitude: -122.416389,
|
||||
geodb_city_id: '126964',
|
||||
referred_by_username: null,
|
||||
bio_length: 1000,
|
||||
bio: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
@@ -202,5 +205,7 @@ export const jamesProfile: ProfileRow = {
|
||||
},
|
||||
],
|
||||
},
|
||||
bio_text: 'the futa in futarchy',
|
||||
bio_tsv: 'the futa in futarchy',
|
||||
age: 32,
|
||||
}
|
||||
|
||||
@@ -4,10 +4,8 @@ import {
|
||||
type CreateEmailOptions,
|
||||
} from 'resend'
|
||||
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 })
|
||||
@@ -17,18 +15,15 @@ export const sendEmail = async (
|
||||
options?: CreateEmailRequestOptions
|
||||
) => {
|
||||
const resend = getResend()
|
||||
console.log(resend, payload, options)
|
||||
console.debug(resend, payload, options)
|
||||
|
||||
async function sendEmailThrottle(data: any, options: any) {
|
||||
if (!resend) return { data: null, error: 'No Resend client' }
|
||||
return limit(() => resend.emails.send(data, options))
|
||||
}
|
||||
if (!resend) return null
|
||||
|
||||
const { data, error } = await sendEmailThrottle(
|
||||
{ replyTo: 'Compass <no-reply@compassmeet.com>', ...payload },
|
||||
const { data, error } = await resend.emails.send(
|
||||
{ replyTo: 'Compass <hello@compassmeet.com>', ...payload },
|
||||
options
|
||||
)
|
||||
console.log('resend.emails.send', data, error)
|
||||
console.debug('resend.emails.send', data, error)
|
||||
|
||||
if (error) {
|
||||
log.error(
|
||||
@@ -39,6 +34,9 @@ export const sendEmail = async (
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -47,12 +45,12 @@ const getResend = () => {
|
||||
if (resend) return resend
|
||||
|
||||
if (!process.env.RESEND_KEY) {
|
||||
console.log('No RESEND_KEY, skipping email send')
|
||||
console.debug('No RESEND_KEY, skipping email send')
|
||||
return
|
||||
}
|
||||
|
||||
const apiKey = process.env.RESEND_KEY as string
|
||||
// console.log(`RESEND_KEY: ${apiKey}`)
|
||||
// console.debug(`RESEND_KEY: ${apiKey}`)
|
||||
resend = new Resend(apiKey)
|
||||
return resend
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@ if (require.main === module) {
|
||||
const email = process.argv[2]
|
||||
if (!email) {
|
||||
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)
|
||||
}
|
||||
|
||||
sendTestEmail(email)
|
||||
.then(() => console.log('Email sent successfully!'))
|
||||
.then(() => console.debug('Email sent successfully!'))
|
||||
.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 {type User} from 'common/user'
|
||||
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";
|
||||
|
||||
interface NewEndorsementEmailProps {
|
||||
@@ -74,7 +74,7 @@ export const NewEndorsementEmail = ({
|
||||
|
||||
NewEndorsementEmail.PreviewProps = {
|
||||
fromUser: jamesUser,
|
||||
onUser: sinclairUser,
|
||||
onUser: mockUser,
|
||||
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.",
|
||||
unsubscribeUrl: 'https://compassmeet.com/unsubscribe',
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {Body, Button, Container, Head, Html, Preview, Section, Text,} from '@react-email/components'
|
||||
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 {jamesProfile, jamesUser, sinclairUser} from './functions/mock'
|
||||
import {jamesProfile, jamesUser, mockUser} from './functions/mock'
|
||||
import {Footer} from "email/utils";
|
||||
|
||||
interface NewMatchEmailProps {
|
||||
@@ -21,7 +21,7 @@ export const NewMatchEmail = ({
|
||||
email
|
||||
}: NewMatchEmailProps) => {
|
||||
const name = onUser.name.split(' ')[0]
|
||||
// const userImgSrc = getLoveOgImageUrl(matchedWithUser, matchedProfile)
|
||||
// const userImgSrc = getOgImageUrl(matchedWithUser, matchedProfile)
|
||||
const userUrl = `https://${DOMAIN}/${matchedWithUser.username}`
|
||||
|
||||
return (
|
||||
@@ -70,7 +70,7 @@ export const NewMatchEmail = ({
|
||||
}
|
||||
|
||||
NewMatchEmail.PreviewProps = {
|
||||
onUser: sinclairUser,
|
||||
onUser: mockUser,
|
||||
matchedWithUser: jamesUser,
|
||||
matchedProfile: jamesProfile,
|
||||
email: 'someone@gmail.com',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {Body, Button, Container, Head, Html, Preview, Section, Text,} from '@react-email/components'
|
||||
import {type User} from 'common/user'
|
||||
import {type ProfileRow} from 'common/love/profile'
|
||||
import {jamesProfile, jamesUser, sinclairUser,} from './functions/mock'
|
||||
import {type ProfileRow} from 'common/profiles/profile'
|
||||
import {jamesProfile, jamesUser, mockUser,} from './functions/mock'
|
||||
import {DOMAIN} from 'common/envs/constants'
|
||||
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 creatorName = fromUser.name
|
||||
const messagesUrl = `https://${DOMAIN}/messages/${channelId}`
|
||||
// const userImgSrc = getLoveOgImageUrl(fromUser, fromUserProfile)
|
||||
// const userImgSrc = getOgImageUrl(fromUser, fromUserProfile)
|
||||
|
||||
return (
|
||||
<Html>
|
||||
@@ -74,7 +74,7 @@ export const NewMessageEmail = ({
|
||||
NewMessageEmail.PreviewProps = {
|
||||
fromUser: jamesUser,
|
||||
fromUserProfile: jamesProfile,
|
||||
toUser: sinclairUser,
|
||||
toUser: mockUser,
|
||||
channelId: 1,
|
||||
email: 'someone@gmail.com',
|
||||
unsubscribeUrl: 'https://compassmeet.com/unsubscribe',
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {Body, Container, Head, Html, Link, Preview, Section, Text,} from '@react-email/components'
|
||||
import {type User} from 'common/user'
|
||||
import {sinclairUser,} from './functions/mock'
|
||||
import {mockUser,} from './functions/mock'
|
||||
import {DOMAIN} from 'common/envs/constants'
|
||||
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 {FilterFields} from "common/filters";
|
||||
|
||||
@@ -140,7 +140,7 @@ const matchSamples = [
|
||||
]
|
||||
|
||||
NewSearchAlertsEmail.PreviewProps = {
|
||||
toUser: sinclairUser,
|
||||
toUser: mockUser,
|
||||
email: 'someone@gmail.com',
|
||||
unsubscribeUrl: 'https://compassmeet.com/unsubscribe',
|
||||
matches: matchSamples,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
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";
|
||||
|
||||
interface Props {
|
||||
@@ -15,40 +14,40 @@ export const Footer = ({
|
||||
<hr style={{border: 'none', borderTop: '1px solid #e0e0e0', margin: '10px 0'}}/>
|
||||
<Row>
|
||||
<Column align="center">
|
||||
<Link href={githubRepo} target="_blank">
|
||||
<Link href={`https://${DOMAIN}/github`} target="_blank">
|
||||
<Img
|
||||
src={`https://${DOMAIN}/images/github-logo.png`}
|
||||
width="24"
|
||||
height="24"
|
||||
alt="GitHub"
|
||||
style={{ display: "inline-block", margin: "0 4px" }}
|
||||
style={{display: "inline-block", margin: "0 4px"}}
|
||||
/>
|
||||
</Link>
|
||||
<Link href={discordLink} target="_blank">
|
||||
<Link href={`https://${DOMAIN}/discord`} target="_blank">
|
||||
<Img
|
||||
src={`https://${DOMAIN}/images/discord-logo.png`}
|
||||
width="24"
|
||||
height="24"
|
||||
alt="Discord"
|
||||
style={{ display: "inline-block", margin: "0 4px" }}
|
||||
style={{display: "inline-block", margin: "0 4px"}}
|
||||
/>
|
||||
</Link>
|
||||
<Link href={patreonLink} target="_blank">
|
||||
<Link href={`https://${DOMAIN}/patreon`} target="_blank">
|
||||
<Img
|
||||
src={`https://${DOMAIN}/images/patreon-logo.png`}
|
||||
width="24"
|
||||
height="24"
|
||||
alt="Patreon"
|
||||
style={{ display: "inline-block", margin: "0 4px" }}
|
||||
style={{display: "inline-block", margin: "0 4px"}}
|
||||
/>
|
||||
</Link>
|
||||
<Link href={paypalLink} target="_blank">
|
||||
<Link href={`https://${DOMAIN}/paypal`} target="_blank">
|
||||
<Img
|
||||
src={`https://${DOMAIN}/images/paypal-logo.png`}
|
||||
width="24"
|
||||
height="24"
|
||||
alt="PayPal"
|
||||
style={{ display: "inline-block", margin: "0 4px" }}
|
||||
style={{display: "inline-block", margin: "0 4px"}}
|
||||
/>
|
||||
</Link>
|
||||
</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"
|
||||
},
|
||||
{
|
||||
"bucket": "compass-130ba-private.firebasestorage.app",
|
||||
"bucket": "compass-130ba-private",
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,7 @@ service firebase.storage {
|
||||
match /b/{bucket}/o {
|
||||
match /{allPaths=**} {
|
||||
allow read;
|
||||
// Don't require auth, as dream uploads can be done by anyone
|
||||
allow write: if request.resource.size <= 10 * 1024 * 1024; // 10MB
|
||||
allow write: if request.auth != null && request.resource.size <= 10 * 1024 * 1024;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ runScript(async ({ pg }) => {
|
||||
}
|
||||
}
|
||||
|
||||
// console.log('updates', updates.slice(0, 10))
|
||||
// console.debug('updates', updates.slice(0, 10))
|
||||
// return
|
||||
|
||||
let count = 0
|
||||
|
||||
@@ -24,7 +24,7 @@ runScript(async ({ pg }) => {
|
||||
})
|
||||
|
||||
const getNodes = async (pg: SupabaseDirectClient, nodeName: string) => {
|
||||
console.log(`\nSearching comments for ${nodeName}...`)
|
||||
console.debug(`\nSearching comments for ${nodeName}...`)
|
||||
const commentQuery = renderSql(
|
||||
select('id, user_id, on_user_id, content'),
|
||||
from('profile_comments'),
|
||||
@@ -32,15 +32,15 @@ const getNodes = async (pg: SupabaseDirectClient, nodeName: string) => {
|
||||
)
|
||||
const comments = await pg.manyOrNone(commentQuery)
|
||||
|
||||
console.log(`Found ${comments.length} comments:`)
|
||||
console.debug(`Found ${comments.length} comments:`)
|
||||
comments.forEach((comment) => {
|
||||
console.log('\nComment ID:', comment.id)
|
||||
console.log('From user:', comment.user_id)
|
||||
console.log('On user:', comment.on_user_id)
|
||||
console.log('Content:', JSON.stringify(comment.content))
|
||||
console.debug('\nComment ID:', comment.id)
|
||||
console.debug('From user:', comment.user_id)
|
||||
console.debug('On user:', comment.on_user_id)
|
||||
console.debug('Content:', JSON.stringify(comment.content))
|
||||
})
|
||||
|
||||
console.log(`\nSearching private messages for ${nodeName}...`)
|
||||
console.debug(`\nSearching private messages for ${nodeName}...`)
|
||||
const messageQuery = renderSql(
|
||||
select('id, user_id, channel_id, content'),
|
||||
from('private_user_messages'),
|
||||
@@ -48,15 +48,15 @@ const getNodes = async (pg: SupabaseDirectClient, nodeName: string) => {
|
||||
)
|
||||
const messages = await pg.manyOrNone(messageQuery)
|
||||
|
||||
console.log(`Found ${messages.length} private messages:`)
|
||||
console.debug(`Found ${messages.length} private messages:`)
|
||||
messages.forEach((msg) => {
|
||||
console.log('\nMessage ID:', msg.id)
|
||||
console.log('From user:', msg.user_id)
|
||||
console.log('Channel:', msg.channel_id)
|
||||
console.log('Content:', JSON.stringify(msg.content))
|
||||
console.debug('\nMessage ID:', msg.id)
|
||||
console.debug('From user:', msg.user_id)
|
||||
console.debug('Channel:', msg.channel_id)
|
||||
console.debug('Content:', JSON.stringify(msg.content))
|
||||
})
|
||||
|
||||
console.log(`\nSearching profiles for ${nodeName}...`)
|
||||
console.debug(`\nSearching profiles for ${nodeName}...`)
|
||||
const users = renderSql(
|
||||
select('user_id, bio'),
|
||||
from('profiles'),
|
||||
@@ -64,9 +64,9 @@ const getNodes = async (pg: SupabaseDirectClient, nodeName: string) => {
|
||||
)
|
||||
|
||||
const usersWithMentions = await pg.manyOrNone(users)
|
||||
console.log(`Found ${usersWithMentions.length} users:`)
|
||||
console.debug(`Found ${usersWithMentions.length} users:`)
|
||||
usersWithMentions.forEach((user) => {
|
||||
console.log('\nUser ID:', user.user_id)
|
||||
console.log('Bio:', JSON.stringify(user.bio))
|
||||
console.debug('\nUser ID:', user.user_id)
|
||||
console.debug('Bio:', JSON.stringify(user.bio))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -14,10 +14,6 @@ select
|
||||
from
|
||||
temp_users;
|
||||
|
||||
-- Rename temp_love_messages
|
||||
-- alter table temp_love_messages
|
||||
-- rename to private_user_messages;
|
||||
|
||||
-- alter table private_user_messages
|
||||
-- alter column channel_id set not null,
|
||||
-- alter column content set not null,
|
||||
@@ -12,7 +12,7 @@ DB_USER="postgres"
|
||||
PORT="5432"
|
||||
|
||||
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 \
|
||||
@@ -25,8 +25,5 @@ psql -U $DB_USER -d postgres -h $DB_NAME -p $PORT -w \
|
||||
# -f ../supabase/private_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"
|
||||
)
|
||||
@@ -178,7 +178,7 @@ async function getTableInfo(pg: SupabaseDirectClient, tableName: string) {
|
||||
}
|
||||
|
||||
async function getFunctions(pg: SupabaseDirectClient) {
|
||||
console.log('Getting functions')
|
||||
console.debug('Getting functions')
|
||||
const rows = await pg.manyOrNone<{
|
||||
function_name: string
|
||||
definition: string
|
||||
@@ -196,7 +196,7 @@ async function getFunctions(pg: SupabaseDirectClient) {
|
||||
}
|
||||
|
||||
async function getViews(pg: SupabaseDirectClient) {
|
||||
console.log('Getting views')
|
||||
console.debug('Getting views')
|
||||
return pg.manyOrNone<{ view_name: string; definition: string }>(
|
||||
`SELECT
|
||||
table_name AS view_name,
|
||||
@@ -214,7 +214,7 @@ async function generateSQLFiles(pg: SupabaseDirectClient) {
|
||||
(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(
|
||||
tables.map((table) => getTableInfo(pg, table))
|
||||
)
|
||||
@@ -331,7 +331,7 @@ async function generateSQLFiles(pg: SupabaseDirectClient) {
|
||||
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`
|
||||
|
||||
for (const func of functions) {
|
||||
@@ -340,7 +340,7 @@ async function generateSQLFiles(pg: SupabaseDirectClient) {
|
||||
|
||||
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`
|
||||
|
||||
for (const view of views) {
|
||||
@@ -350,7 +350,7 @@ async function generateSQLFiles(pg: SupabaseDirectClient) {
|
||||
|
||||
await fs.writeFile(`${outputDir}/views.sql`, viewsContent)
|
||||
|
||||
console.log('Prettifying SQL files...')
|
||||
console.debug('Prettifying SQL files...')
|
||||
execSync(
|
||||
`prettier --write ${outputDir}/*.sql --ignore-path ../supabase/.gitignore`
|
||||
)
|
||||
|
||||
@@ -30,7 +30,7 @@ const removeNodesOfType = (
|
||||
runScript(async ({ pg }) => {
|
||||
const nodeType = 'linkPreview'
|
||||
|
||||
console.log('\nSearching comments for linkPreviews...')
|
||||
console.debug('\nSearching comments for linkPreviews...')
|
||||
const commentQuery = renderSql(
|
||||
select('id, content'),
|
||||
from('profile_comments'),
|
||||
@@ -38,21 +38,21 @@ runScript(async ({ pg }) => {
|
||||
)
|
||||
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) {
|
||||
const newContent = removeNodesOfType(comment.content, nodeType)
|
||||
console.log('before', comment.content)
|
||||
console.log('after', newContent)
|
||||
console.debug('before', comment.content)
|
||||
console.debug('after', newContent)
|
||||
|
||||
await pg.none('update profile_comments set content = $1 where id = $2', [
|
||||
newContent,
|
||||
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(
|
||||
select('id, content'),
|
||||
from('private_user_messages'),
|
||||
@@ -60,17 +60,17 @@ runScript(async ({ pg }) => {
|
||||
)
|
||||
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) {
|
||||
const newContent = removeNodesOfType(msg.content, nodeType)
|
||||
console.log('before', JSON.stringify(msg.content, null, 2))
|
||||
console.log('after', JSON.stringify(newContent, null, 2))
|
||||
console.debug('before', JSON.stringify(msg.content, null, 2))
|
||||
console.debug('after', JSON.stringify(newContent, null, 2))
|
||||
|
||||
await pg.none(
|
||||
'update private_user_messages set content = $1 where id = $2',
|
||||
[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 {loadSecretsToEnv} from 'common/secrets'
|
||||
import {createSupabaseDirectClient, type SupabaseDirectClient,} from 'shared/supabase/init'
|
||||
import {getServiceAccountCredentials} from "shared/firebase-utils";
|
||||
|
||||
initAdmin()
|
||||
import {refreshConfig} from "common/envs/prod";
|
||||
|
||||
export const runScript = async (
|
||||
main: (services: { pg: SupabaseDirectClient }) => Promise<any> | any
|
||||
) => {
|
||||
const credentials = getServiceAccountCredentials()
|
||||
|
||||
await loadSecretsToEnv(credentials)
|
||||
initAdmin()
|
||||
await initEnvVariables()
|
||||
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()
|
||||
console.debug('runScript: running main...')
|
||||
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 { Notification } from 'common/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 targetPrivateUser = await getPrivateUser(target_id)
|
||||
@@ -16,7 +16,7 @@ export const createLoveLikeNotification = async (like: Row<'love_likes'>) => {
|
||||
|
||||
const { sendToBrowser } = getNotificationDestinationsForUser(
|
||||
targetPrivateUser,
|
||||
'new_love_like'
|
||||
'new_profile_like'
|
||||
)
|
||||
if (!sendToBrowser) return
|
||||
|
||||
@@ -24,11 +24,11 @@ export const createLoveLikeNotification = async (like: Row<'love_likes'>) => {
|
||||
const notification: Notification = {
|
||||
id,
|
||||
userId: target_id,
|
||||
reason: 'new_love_like',
|
||||
reason: 'new_profile_like',
|
||||
createdTime: Date.now(),
|
||||
isSeen: false,
|
||||
sourceId: like_id,
|
||||
sourceType: 'love_like',
|
||||
sourceType: 'profile_like',
|
||||
sourceUpdateType: 'created',
|
||||
sourceUserName: profile.user.name,
|
||||
sourceUserUsername: profile.user.username,
|
||||
@@ -39,8 +39,8 @@ export const createLoveLikeNotification = async (like: Row<'love_likes'>) => {
|
||||
return await insertNotificationToSupabase(notification, pg)
|
||||
}
|
||||
|
||||
export const createLoveShipNotification = async (
|
||||
ship: Row<'love_ships'>,
|
||||
export const createProfileShipNotification = async (
|
||||
ship: Row<'profile_ships'>,
|
||||
recipientId: string
|
||||
) => {
|
||||
const { creator_id, target1_id, target2_id, ship_id } = ship
|
||||
@@ -61,7 +61,7 @@ export const createLoveShipNotification = async (
|
||||
|
||||
const { sendToBrowser } = getNotificationDestinationsForUser(
|
||||
targetPrivateUser,
|
||||
'new_love_ship'
|
||||
'new_profile_ship'
|
||||
)
|
||||
if (!sendToBrowser) return
|
||||
|
||||
@@ -69,11 +69,11 @@ export const createLoveShipNotification = async (
|
||||
const notification: Notification = {
|
||||
id,
|
||||
userId: recipientId,
|
||||
reason: 'new_love_ship',
|
||||
reason: 'new_profile_ship',
|
||||
createdTime: Date.now(),
|
||||
isSeen: false,
|
||||
sourceId: ship_id,
|
||||
sourceType: 'love_ship',
|
||||
sourceType: 'profile_ship',
|
||||
sourceUpdateType: 'created',
|
||||
sourceUserName: profile.user.name,
|
||||
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 = () => {
|
||||
let keyPath = ENV_CONFIG.googleApplicationCredentials
|
||||
// console.log('Using GOOGLE_APPLICATION_CREDENTIALS:', keyPath)
|
||||
// console.debug('Using GOOGLE_APPLICATION_CREDENTIALS:', keyPath)
|
||||
if (!keyPath) {
|
||||
// throw new Error(
|
||||
// `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('/')) {
|
||||
// Make relative paths relative to the current file
|
||||
keyPath = __dirname + '/' + keyPath
|
||||
// console.log(keyPath)
|
||||
// console.debug(keyPath)
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -41,11 +41,11 @@ export async function deleteUserFiles(username: string) {
|
||||
const [files] = await bucket.getFiles({prefix: path});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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()
|
||||
return await upload(userId, Buffer.from(buffer), bucket)
|
||||
} catch (e) {
|
||||
console.log('error generating avatar', e)
|
||||
console.debug('error generating avatar', e)
|
||||
return `https://${DOMAIN}/images/default-avatar.png`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ export const constructPrefixTsQuery = (term: string) => {
|
||||
.replace(/'/g, "''")
|
||||
.replace(/[!&|():*<>]/g, '')
|
||||
.trim()
|
||||
console.log(`Term: "${sanitized}"`)
|
||||
console.debug(`Term: "${sanitized}"`)
|
||||
if (sanitized === '') return ''
|
||||
const tokens = sanitized.split(/\s+/)
|
||||
return tokens.join(' & ') + ':*'
|
||||
|
||||
@@ -9,12 +9,12 @@ export const initAdmin = () => {
|
||||
if (IS_LOCAL) {
|
||||
try {
|
||||
const serviceAccount = getServiceAccountCredentials()
|
||||
// console.log(serviceAccount)
|
||||
// console.debug(serviceAccount)
|
||||
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
|
||||
}
|
||||
console.log(`Initializing connection to ${serviceAccount.project_id} Firebase...`)
|
||||
console.debug(`Initializing connection to ${serviceAccount.project_id} Firebase...`)
|
||||
return admin.initializeApp({
|
||||
projectId: serviceAccount.project_id,
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ function writeLog(
|
||||
// record error properties in GCP if you just do log(err)
|
||||
output['error'] = msg
|
||||
}
|
||||
console.log(JSON.stringify(output, replacer))
|
||||
console.debug(JSON.stringify(output, replacer))
|
||||
} else {
|
||||
const category = Object.values(pick(data, DISPLAY_CATEGORY_KEYS)).join()
|
||||
const categoryLabel = category ? dim(category) + ' ' : ''
|
||||
|
||||
@@ -104,7 +104,7 @@ export class MetricWriter {
|
||||
for (const entry of freshEntries) {
|
||||
entry.fresh = false
|
||||
}
|
||||
if (!IS_GOOGLE_CLOUD) {
|
||||
if (IS_GOOGLE_CLOUD) {
|
||||
log.debug('Writing GCP metrics.', {entries: freshEntries})
|
||||
if (this.instance == null) {
|
||||
this.instance = await getInstanceInfo()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { areGenderCompatible } from 'common/love/compatibility-util'
|
||||
import { type Profile, type ProfileRow } from 'common/love/profile'
|
||||
import { areGenderCompatible } from 'common/profiles/compatibility-util'
|
||||
import { type Profile, type ProfileRow } from 'common/profiles/profile'
|
||||
import { type User } from 'common/user'
|
||||
import { Row } from 'common/supabase/utils'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
@@ -14,20 +14,24 @@ export function convertRow(row: ProfileAndUserRow): Profile
|
||||
export function convertRow(row: ProfileAndUserRow | undefined): Profile | null {
|
||||
if (!row) return null
|
||||
|
||||
return {
|
||||
// Remove internal/search-only fields from the returned profile row
|
||||
const profile: any = {
|
||||
...row,
|
||||
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) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
return await pg.oneOrNone(
|
||||
`
|
||||
select
|
||||
${LOVER_COLS}
|
||||
${PROFILE_COLS}
|
||||
from
|
||||
profiles
|
||||
join
|
||||
@@ -45,7 +49,7 @@ export const getProfiles = async (userIds: string[]) => {
|
||||
return await pg.map(
|
||||
`
|
||||
select
|
||||
${LOVER_COLS}
|
||||
${PROFILE_COLS}
|
||||
from
|
||||
profiles
|
||||
join
|
||||
@@ -63,7 +67,7 @@ export const getGenderCompatibleProfiles = async (profile: ProfileRow) => {
|
||||
const profiles = await pg.map(
|
||||
`
|
||||
select
|
||||
${LOVER_COLS}
|
||||
${PROFILE_COLS}
|
||||
from profiles
|
||||
join
|
||||
users on users.id = profiles.user_id
|
||||
@@ -88,7 +92,7 @@ export const getCompatibleProfiles = async (
|
||||
return await pg.map(
|
||||
`
|
||||
select
|
||||
${LOVER_COLS}
|
||||
${PROFILE_COLS}
|
||||
from profiles
|
||||
join
|
||||
users on users.id = profiles.user_id
|
||||
@@ -118,9 +122,9 @@ export const getCompatibleProfiles = async (
|
||||
|
||||
export const getCompatibilityAnswers = async (userIds: string[]) => {
|
||||
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)
|
||||
`,
|
||||
[userIds]
|
||||
@@ -11,7 +11,7 @@ export {SupabaseClient} from 'common/supabase/utils'
|
||||
export const pgp = pgPromise({
|
||||
error(err: any, e: pgPromise.IEventContext) {
|
||||
// Read more: https://node-postgres.com/apis/pool#error
|
||||
log.error('pgPromise background error', {
|
||||
log.error(`pgPromise background error: ${err?.detail}`, {
|
||||
error: err,
|
||||
event: e,
|
||||
})
|
||||
@@ -64,7 +64,7 @@ const newClient = (
|
||||
...settings,
|
||||
}
|
||||
|
||||
// console.log(config)
|
||||
// console.debug(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 {getWebsocketUrl} from "common/api/utils";
|
||||
|
||||
// Extend the type definition locally
|
||||
interface HeartbeatWebSocket extends WebSocket {
|
||||
isAlive?: boolean
|
||||
}
|
||||
|
||||
const SWITCHBOARD = new Switchboard()
|
||||
|
||||
// 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 {
|
||||
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 {
|
||||
const msg = parseMessage(data)
|
||||
const { type, txid } = msg
|
||||
@@ -129,21 +134,26 @@ export function listen(server: HttpServer, path: string) {
|
||||
let deadConnectionCleaner: NodeJS.Timeout | undefined
|
||||
wss.on('listening', () => {
|
||||
log.info(`Web socket server listening on ${path}. ${getWebsocketUrl()}`)
|
||||
deadConnectionCleaner = setInterval(function ping() {
|
||||
const now = Date.now()
|
||||
for (const ws of wss.clients) {
|
||||
const lastSeen = SWITCHBOARD.getClient(ws).lastSeen
|
||||
if (lastSeen < now - CONNECTION_TIMEOUT_MS) {
|
||||
ws.terminate()
|
||||
deadConnectionCleaner = setInterval(() => {
|
||||
for (const ws of wss.clients as Set<HeartbeatWebSocket>) {
|
||||
if (ws.isAlive === false) {
|
||||
log.debug('Terminating dead connection');
|
||||
ws.terminate();
|
||||
continue;
|
||||
}
|
||||
ws.isAlive = false;
|
||||
// log.debug('Sending ping to client');
|
||||
ws.ping();
|
||||
}
|
||||
}, CONNECTION_TIMEOUT_MS)
|
||||
}, 25000);
|
||||
})
|
||||
wss.on('error', (err) => {
|
||||
log.error('Error on websocket server.', { error: err })
|
||||
})
|
||||
wss.on('connection', (ws) => {
|
||||
// todo: should likely kill connections that haven't sent any ping for a long time
|
||||
wss.on('connection', (ws: HeartbeatWebSocket) => {
|
||||
ws.isAlive = true;
|
||||
// log.debug('Received pong from client');
|
||||
ws.on('pong', () => (ws.isAlive = true));
|
||||
metrics.inc('ws/connections_established')
|
||||
metrics.set('ws/open_connections', wss.clients.size)
|
||||
log.debug('WS client connected.')
|
||||
|
||||
@@ -8,6 +8,13 @@ CREATE TABLE IF NOT EXISTS bookmarked_searches (
|
||||
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
|
||||
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
|
||||
FOR SELECT USING (true);
|
||||
|
||||
DROP POLICY IF EXISTS "self delete" ON bookmarked_searches;
|
||||
CREATE POLICY "self delete" ON bookmarked_searches
|
||||
FOR DELETE USING (creator_id = firebase_uid());
|
||||
|
||||
DROP POLICY IF EXISTS "self insert" ON bookmarked_searches;
|
||||
CREATE POLICY "self insert" ON bookmarked_searches
|
||||
FOR INSERT WITH CHECK (creator_id = firebase_uid());
|
||||
|
||||
DROP POLICY IF EXISTS "self update" ON bookmarked_searches;
|
||||
CREATE POLICY "self update" ON bookmarked_searches
|
||||
FOR UPDATE USING (creator_id = firebase_uid());
|
||||
-- DROP POLICY IF EXISTS "self delete" ON bookmarked_searches;
|
||||
-- CREATE POLICY "self delete" ON bookmarked_searches
|
||||
-- FOR DELETE USING (creator_id = firebase_uid());
|
||||
--
|
||||
-- DROP POLICY IF EXISTS "self insert" ON bookmarked_searches;
|
||||
-- CREATE POLICY "self insert" ON bookmarked_searches
|
||||
-- FOR INSERT WITH CHECK (creator_id = firebase_uid());
|
||||
--
|
||||
-- DROP POLICY IF EXISTS "self update" ON bookmarked_searches;
|
||||
-- CREATE POLICY "self update" ON bookmarked_searches
|
||||
-- FOR UPDATE USING (creator_id = firebase_uid());
|
||||
|
||||
-- Indexes
|
||||
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
|
||||
or replace function public.to_jsonb (jsonb) returns jsonb language sql immutable parallel SAFE strict as $function$ select $1 $function$;
|
||||
|
||||
create
|
||||
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
|
||||
$function$;
|
||||
|
||||
create
|
||||
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
|
||||
$function$;
|
||||
-- create
|
||||
-- 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
|
||||
-- $function$;
|
||||
--
|
||||
-- create
|
||||
-- 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
|
||||
-- $function$;
|
||||
|
||||
create
|
||||
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
|
||||
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
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
love_questions.*,
|
||||
COUNT(love_compatibility_answers.question_id) as answer_count
|
||||
compatibility_prompts.*,
|
||||
COUNT(compatibility_answers.question_id) as answer_count
|
||||
FROM
|
||||
love_questions
|
||||
compatibility_prompts
|
||||
LEFT JOIN
|
||||
love_compatibility_answers ON love_questions.id = love_compatibility_answers.question_id
|
||||
WHERE love_questions.answer_type='compatibility_multiple_choice'
|
||||
compatibility_answers ON compatibility_prompts.id = compatibility_answers.question_id
|
||||
WHERE compatibility_prompts.answer_type='compatibility_multiple_choice'
|
||||
GROUP BY
|
||||
love_questions.id
|
||||
compatibility_prompts.id
|
||||
ORDER BY
|
||||
answer_count DESC;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
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
|
||||
RETURN QUERY
|
||||
SELECT
|
||||
love_answers.question_id,
|
||||
love_answers.created_time,
|
||||
love_answers.free_response,
|
||||
love_answers.multiple_choice,
|
||||
love_answers.integer,
|
||||
compatibility_answers_free.question_id,
|
||||
compatibility_answers_free.created_time,
|
||||
compatibility_answers_free.free_response,
|
||||
compatibility_answers_free.multiple_choice,
|
||||
compatibility_answers_free.integer,
|
||||
profiles.age,
|
||||
profiles.gender,
|
||||
profiles.city,
|
||||
@@ -36,11 +36,11 @@ SELECT
|
||||
FROM
|
||||
profiles
|
||||
JOIN
|
||||
love_answers ON profiles.user_id = love_answers.creator_id
|
||||
compatibility_answers_free ON profiles.user_id = compatibility_answers_free.creator_id
|
||||
join
|
||||
users on profiles.user_id = users.id
|
||||
WHERE
|
||||
love_answers.question_id = p_question_id
|
||||
order by love_answers.created_time desc;
|
||||
compatibility_answers_free.question_id = p_question_id
|
||||
order by compatibility_answers_free.created_time desc;
|
||||
END;
|
||||
$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_user_messages.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/love_compatibility_answers.sql
|
||||
\i backend/supabase/love_likes.sql
|
||||
\i backend/supabase/love_questions.sql
|
||||
\i backend/supabase/love_ships.sql
|
||||
\i backend/supabase/love_stars.sql
|
||||
\i backend/supabase/love_waitlist.sql
|
||||
\i backend/supabase/compatibility_answers.sql
|
||||
\i backend/supabase/profile_likes.sql
|
||||
\i backend/supabase/compatibility_prompts.sql
|
||||
\i backend/supabase/profile_ships.sql
|
||||
\i backend/supabase/profile_stars.sql
|
||||
\i backend/supabase/user_waitlist.sql
|
||||
\i backend/supabase/user_events.sql
|
||||
\i backend/supabase/user_notifications.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
|
||||
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 $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
@@ -30,12 +36,10 @@ END$$;
|
||||
ALTER TABLE private_user_message_channel_members ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Indexes
|
||||
DROP INDEX IF EXISTS pumcm_members_idx;
|
||||
CREATE INDEX pumcm_members_idx
|
||||
CREATE INDEX IF NOT EXISTS pumcm_members_idx
|
||||
ON public.private_user_message_channel_members
|
||||
USING btree (channel_id, user_id);
|
||||
|
||||
DROP INDEX IF EXISTS unique_user_channel;
|
||||
CREATE UNIQUE INDEX unique_user_channel
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS unique_user_channel
|
||||
ON public.private_user_message_channel_members
|
||||
USING btree (channel_id, user_id);
|
||||
|
||||
@@ -9,12 +9,12 @@ CREATE TABLE IF NOT EXISTS private_user_message_channels (
|
||||
-- Row Level Security
|
||||
ALTER TABLE private_user_message_channels ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
|
||||
|
||||
-- Policies
|
||||
DROP POLICY IF EXISTS "public read" ON private_user_message_channels;
|
||||
|
||||
CREATE POLICY "public read" ON private_user_message_channels
|
||||
FOR ALL
|
||||
USING (true);
|
||||
FOR SELECT USING (true);
|
||||
|
||||
-- Indexes
|
||||
-- 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 (
|
||||
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,
|
||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||
old_id BIGINT,
|
||||
user_id TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
visibility TEXT DEFAULT 'private'::TEXT NOT NULL,
|
||||
CONSTRAINT private_user_messages_channel_id_fkey
|
||||
FOREIGN KEY (channel_id)
|
||||
@@ -12,11 +14,16 @@ CREATE TABLE IF NOT EXISTS private_user_messages (
|
||||
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
|
||||
ALTER TABLE private_user_messages ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Indexes
|
||||
DROP INDEX IF 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);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user