168 Commits
1.2.0 ... 1.4.0

Author SHA1 Message Date
MartinBraquet
204a35d026 Release 1.4 2025-10-18 12:24:13 +02:00
MartinBraquet
fb2841f198 Update compat answer box 2025-10-18 12:23:47 +02:00
MartinBraquet
5de055c977 Improve importance radio contrast 2025-10-18 12:14:35 +02:00
MartinBraquet
084659ea3d Remove debug 2025-10-18 12:14:20 +02:00
MartinBraquet
c1a414afab Make votes sortable 2025-10-18 11:49:54 +02:00
MartinBraquet
a5747034d6 Fix props name 2025-10-18 10:40:35 +02:00
MartinBraquet
fda52fec97 Move proposal up and hide by default 2025-10-18 10:39:50 +02:00
MartinBraquet
e38ec79618 remove quote 2025-10-18 02:50:05 +02:00
MartinBraquet
1ef125db12 Fix md format 2025-10-18 02:46:07 +02:00
MartinBraquet
b580b640bd Remove unused react 2025-10-18 02:39:24 +02:00
MartinBraquet
214bddaca4 Add contact links 2025-10-18 02:36:54 +02:00
MartinBraquet
065d489869 Add contact form 2025-10-18 02:20:31 +02:00
MartinBraquet
46ffefbbb9 Add anonymous option for votes 2025-10-18 00:53:35 +02:00
MartinBraquet
a19db3bca9 Clean 2025-10-18 00:20:32 +02:00
MartinBraquet
2c8d8d9989 Clean 2025-10-18 00:12:53 +02:00
MartinBraquet
d52943e31e Fix 2025-10-17 23:24:08 +02:00
MartinBraquet
3eababb742 Fix 2025-10-17 23:19:20 +02:00
MartinBraquet
8a954d3c20 Add voting / proposal page 2025-10-17 23:15:15 +02:00
MartinBraquet
8516901032 Allow get notified for anyone 2025-10-17 19:04:57 +02:00
MartinBraquet
3f2d246fec Fix short bios not showing when sorting by compatibility 2025-10-17 16:55:44 +02:00
MartinBraquet
58fdaa26ca Move to distance filtering to improve accuracy and speed 2025-10-17 16:43:27 +02:00
MartinBraquet
7dc1a8790d Fix 2025-10-17 14:53:55 +02:00
MartinBraquet
70c9ec1d73 Show full political names 2025-10-17 14:02:04 +02:00
MartinBraquet
2bcbbc96ad Add political options 2025-10-17 13:55:48 +02:00
MartinBraquet
527d36a159 Move want kids closer to connection type 2025-10-17 13:50:46 +02:00
MartinBraquet
2ce21247ee Add romantic style (poly, mono, other) 2025-10-17 13:42:32 +02:00
MartinBraquet
8ea6c406e0 Add webhook to report to discord 2025-10-16 20:59:46 +02:00
MartinBraquet
e22f50ecd3 Show loading indicator 2025-10-16 15:28:29 +02:00
MartinBraquet
20dcd98fdf Allow unauth requests to get-messages-count (used in public stats) 2025-10-16 15:16:21 +02:00
MartinBraquet
bc5708857a Improve onboarding UI 2025-10-16 14:37:17 +02:00
MartinBraquet
b9c045ebfb Do not render sign up button, redirect to home 2025-10-16 14:22:53 +02:00
MartinBraquet
c69bd7018e Use compass loading sign 2025-10-16 14:08:34 +02:00
MartinBraquet
078d149175 Redirect to profiles grid after sign up 2025-10-16 14:08:24 +02:00
MartinBraquet
be9f0bd061 Add 20 core compatibility prompts 2025-10-16 13:48:00 +02:00
MartinBraquet
a4723563f5 Keep blue loading circle for buttons 2025-10-16 13:41:03 +02:00
MartinBraquet
1fdcd24f28 Improve design of loading indicator 2025-10-16 12:42:38 +02:00
MartinBraquet
a43480db92 Increase API_RATE_LIMIT_PER_MIN_UNAUTHED 2025-10-16 01:27:24 +02:00
MartinBraquet
e85a072f1c Add user loaded log 2025-10-16 01:24:05 +02:00
MartinBraquet
bbfa2a4eab Wait longer for user to appear 2025-10-16 01:23:12 +02:00
MartinBraquet
2f2db4ded8 Rollback toast error as it shows randomly 2025-10-16 00:48:38 +02:00
MartinBraquet
7296a0d2cd Remove rate limit for endpoints not prone to scraping 2025-10-16 00:41:21 +02:00
MartinBraquet
08e02b6ac0 Add too many requests toast 2025-10-16 00:28:25 +02:00
MartinBraquet
715811d7fd Commetn 2025-10-16 00:28:10 +02:00
MartinBraquet
c7d6ae6995 Hide log 2025-10-16 00:27:58 +02:00
MartinBraquet
b1d1396944 Fix import 2025-10-15 23:52:46 +02:00
MartinBraquet
25a319710e Fix import 2025-10-15 23:50:27 +02:00
MartinBraquet
796b13dd62 Add toast error for too many requests 2025-10-15 23:47:01 +02:00
MartinBraquet
8197863ac5 Clean auth and rate limiting 2025-10-15 23:37:24 +02:00
MartinBraquet
89bd164d43 Add authed 2025-10-15 23:20:20 +02:00
MartinBraquet
80d7061e5f Pre commit 2025-10-15 22:50:50 +02:00
MartinBraquet
c49bac3a09 Make API calls authed 2025-10-15 22:42:26 +02:00
MartinBraquet
06d53fe801 Redirect if logged out in /notifications 2025-10-15 22:32:58 +02:00
MartinBraquet
15ba529938 Fix "column reference "user_id" is ambiguous" 2025-10-15 19:26:10 +02:00
MartinBraquet
83054d0cd1 Fix link opening in same tab 2025-10-15 17:04:00 +02:00
MartinBraquet
8da486adf2 Optimistically remove starred profile upon deletion 2025-10-15 16:01:11 +02:00
MartinBraquet
32bc3847fa Add option to save / bookmark profiles 2025-10-15 15:45:47 +02:00
MartinBraquet
5d763c18c8 Comment log 2025-10-15 15:45:27 +02:00
MartinBraquet
bd3920cfff Fix pagination for last active sorting 2025-10-14 22:09:20 +02:00
MartinBraquet
06d94332b6 Update keyword search placeholder names 2025-10-14 21:35:37 +02:00
MartinBraquet
50614484d8 Move last above social links 2025-10-14 21:06:08 +02:00
MartinBraquet
c29d3d8c92 Clean 2025-10-14 20:51:06 +02:00
MartinBraquet
26f46af375 Fix unused botBadge 2025-10-14 20:49:36 +02:00
MartinBraquet
32b1491dd0 Fix unused node 2025-10-14 20:48:50 +02:00
MartinBraquet
51b8a6c80a Fix unused open 2025-10-14 20:48:21 +02:00
MartinBraquet
0f63d6d3a0 Remove unused react imports 2025-10-14 20:42:41 +02:00
MartinBraquet
4771b08773 Show and write when the user was last online in their profile 2025-10-14 20:36:58 +02:00
MartinBraquet
9b880101fd Make plot larger on mobile 2025-10-14 19:56:35 +02:00
MartinBraquet
594806d6e8 Improve charts design 2025-10-14 19:37:38 +02:00
MartinBraquet
e9afd4db2f Regen supabase types 2025-10-14 19:11:26 +02:00
MartinBraquet
b23efe4089 Add active members tile and move /charts to /stats 2025-10-14 19:09:56 +02:00
MartinBraquet
e33be41a93 Store last_online_time in user_activity.sql table instead of profiles 2025-10-14 19:09:08 +02:00
MartinBraquet
33b09df872 Reduce chart height 2025-10-14 19:03:30 +02:00
MartinBraquet
e9050d0aa0 Simplify and make grid for organization.tsx 2025-10-14 18:57:59 +02:00
MartinBraquet
baeb2a33fe Simplify and make grid for social 2025-10-14 18:57:52 +02:00
MartinBraquet
4ad89acdc7 Use last_online_time from user_activity instead of profiles 2025-10-14 17:53:29 +02:00
MartinBraquet
7d87af8f5c Add user_activity.sql 2025-10-14 17:52:09 +02:00
MartinBraquet
65c0e84e2a Do not prepend social url if full url is provided 2025-10-14 11:28:48 +02:00
MartinBraquet
7b15d85871 Do not render protocol and subdomain in socials 2025-10-14 11:28:21 +02:00
MartinBraquet
ad8ec0f4fd Add browser dev info 2025-10-14 11:27:39 +02:00
MartinBraquet
2d05d83dd0 Always show relationship questions when connection type includes relationships 2025-10-14 11:02:50 +02:00
MartinBraquet
bd45066b13 Add IDE note 2025-10-13 19:43:06 +02:00
MartinBraquet
8ee4274054 Rename voting members 2025-10-13 19:13:13 +02:00
MartinBraquet
83a7ed4d6b Add stats to organization page 2025-10-13 19:05:41 +02:00
MartinBraquet
07dbd86ac6 Add How fast is Compass growing? to FAQ 2025-10-13 18:49:05 +02:00
MartinBraquet
0e671d2cc0 Add nice stats 2025-10-13 18:42:39 +02:00
MartinBraquet
2d6d3c04ce Add stat box 2025-10-13 18:42:06 +02:00
MartinBraquet
b0148963c7 Remove bookmarked_searches and love_compatibility_answers upon account deletion 2025-10-13 17:35:12 +02:00
MartinBraquet
13356950f3 Wait instead of thread resend emails 2025-10-13 17:27:59 +02:00
MartinBraquet
629bcb30a7 Add health discord webhook and send error message there 2025-10-13 15:13:05 +02:00
MartinBraquet
03721fff1c Do not pass orderBy when processing saved searches 2025-10-13 15:12:24 +02:00
MartinBraquet
2a6911ae3d Move email links to our domain 2025-10-13 13:34:10 +02:00
MartinBraquet
164eddecab Release v1.3.0 2025-10-13 12:39:38 +02:00
MartinBraquet
9eacb38eb9 Show bio in discord message of profile creation 2025-10-13 12:26:40 +02:00
MartinBraquet
20f5cfb9a7 Fix demo 2025-10-13 10:49:19 +02:00
Martin Braquet
6c6c1cc90a Upgrade geodb plan to increase radius and page limit (#14)
* Upgrade geodb plan to increase radius and page limit

* Speed location debounce
2025-10-12 15:58:52 +02:00
MartinBraquet
a32c099cc1 Rename social 2025-10-12 14:54:58 +02:00
MartinBraquet
fe2f832e83 Improve support page 2025-10-12 14:52:39 +02:00
MartinBraquet
868746cc23 Use Atkinson Hyperlegible font 2025-10-12 14:35:59 +02:00
MartinBraquet
3be7a54284 Fix compat modal (had to scroll to see Next on mobile) 2025-10-12 13:13:01 +02:00
MartinBraquet
635e1ec8e2 Add TODO readme info 2025-10-11 23:23:52 +02:00
MartinBraquet
a638a35a76 Upgrade ban logic 2025-10-11 22:58:50 +02:00
MartinBraquet
8cc33d3418 Add massive upgrade text 2025-10-11 21:42:46 +02:00
MartinBraquet
9947f7b967 Fix 2025-10-11 21:42:33 +02:00
MartinBraquet
daf5350f41 Add stem vector search in bio 2025-10-11 21:15:03 +02:00
MartinBraquet
020b9ddb8d Fix 2025-10-11 19:56:54 +02:00
MartinBraquet
23aff9497a Fix 2025-10-11 19:54:25 +02:00
MartinBraquet
3c119396f3 Add demo 2025-10-11 19:51:48 +02:00
MartinBraquet
f7c7c47ac0 Remove backup info from git 2025-10-11 19:44:38 +02:00
MartinBraquet
dbe2369bbe Fix avatar link 2025-10-11 19:44:12 +02:00
MartinBraquet
4e8033d221 Add info about contact 2025-10-11 19:44:02 +02:00
MartinBraquet
97a0f87cbd Use georgia font 2025-10-11 12:15:26 +02:00
MartinBraquet
bfa2713d43 Fix wording 2025-10-11 11:46:38 +02:00
MartinBraquet
fe5e109751 Improve reading 2025-10-10 22:53:30 +02:00
MartinBraquet
8cc96030b1 Speed up placeholder 2025-10-10 22:52:09 +02:00
MartinBraquet
a2b172ad58 Improve charts 2025-10-10 21:31:30 +02:00
MartinBraquet
e756225d8b Move pics above endorsements 2025-10-10 20:58:06 +02:00
MartinBraquet
dd803b604f Update reserved paths 2025-10-10 20:20:10 +02:00
MartinBraquet
b5c961c8ee Hide complete profile button 2025-10-10 20:05:01 +02:00
MartinBraquet
47cd9d227e Add shortBio filter to mobile filters 2025-10-10 19:13:32 +02:00
MartinBraquet
e2be3aafcd Add shortBio filter 2025-10-10 19:03:57 +02:00
MartinBraquet
015fe76c44 Hide profiles with small bio 2025-10-10 18:33:47 +02:00
MartinBraquet
44666aec03 Update post install 2025-10-10 18:33:11 +02:00
MartinBraquet
6a265e4f35 Do not render home before user loads 2025-10-10 18:32:37 +02:00
MartinBraquet
12c7316524 Refactor buttons 2025-10-10 17:04:26 +02:00
MartinBraquet
dcf9741d69 Format required form as step by step onboarding 2025-10-10 16:46:17 +02:00
MartinBraquet
63dd1fdd50 Replace user with voting member and member with volunteer for clarity and inclusion 2025-10-10 15:22:55 +02:00
MartinBraquet
5aa166bbfd Open links in same tab 2025-10-10 15:12:48 +02:00
MartinBraquet
34cbf7093e Skip welcome email if local 2025-10-10 14:51:22 +02:00
MartinBraquet
159d58949e Reformat 2025-10-09 21:51:21 +02:00
MartinBraquet
fcf802b7e3 Refactor bios and add character counter 2025-10-09 21:51:08 +02:00
MartinBraquet
92ff6dadb0 Add email 2025-10-09 20:00:01 +02:00
MartinBraquet
05fa2f9883 Add socials and organization pages 2025-10-09 19:47:32 +02:00
MartinBraquet
71bb8fd784 Commetn 2025-10-09 19:33:51 +02:00
MartinBraquet
16ffd6dfab Fix message view without sign in 2025-10-09 19:30:24 +02:00
MartinBraquet
2661d15910 Remove waitlist 2025-10-09 19:17:15 +02:00
MartinBraquet
394102bb93 Fix avatar icon 2025-10-09 18:37:11 +02:00
MartinBraquet
3585b12dfd Remove maintenance banner 2025-10-09 18:20:53 +02:00
MartinBraquet
423d87d5f1 Remove logs 2025-10-09 18:19:14 +02:00
MartinBraquet
13b13b1104 Fix 2025-10-09 18:02:15 +02:00
MartinBraquet
a77e7b96b7 Move logs to debug status 2025-10-09 17:59:10 +02:00
MartinBraquet
d7213c255c Add client side heartbeat 2025-10-09 17:50:43 +02:00
MartinBraquet
ddeb1dcdb7 Improve ping pong connection duration 2025-10-09 17:38:05 +02:00
MartinBraquet
221cfa3528 Fix websockets not reaching the container and remove v0/ prefix 2025-10-09 16:58:20 +02:00
MartinBraquet
d6f6348ff1 Add maintenance banner 2025-10-09 16:25:47 +02:00
MartinBraquet
0c6afdc98e Add star 2025-10-09 15:14:38 +02:00
MartinBraquet
02a2148b3f Improve messages width 2025-10-09 13:14:38 +02:00
MartinBraquet
36a02268d8 Fix 2025-10-09 11:28:11 +02:00
MartinBraquet
450f07f505 Add private backup 2025-10-09 11:27:24 +02:00
MartinBraquet
777eba9fed Move backup to private storage 2025-10-09 11:23:30 +02:00
MartinBraquet
eaa8fa57d1 Add private bucket 2025-10-09 11:18:37 +02:00
MartinBraquet
200bf479e1 Clean 2025-10-09 00:43:27 +02:00
MartinBraquet
331f409af9 Increase debounce 2025-10-09 00:28:27 +02:00
MartinBraquet
ce875a5e63 Fix Porto not showing 2025-10-09 00:16:58 +02:00
MartinBraquet
638013f835 Update email address 2025-10-08 23:44:37 +02:00
MartinBraquet
1de87cbfec Add welcome email 2025-10-08 23:40:53 +02:00
MartinBraquet
7f3428b36a Factor out unsubscribe url 2025-10-08 23:40:37 +02:00
MartinBraquet
35595ded47 Fix bullets 2025-10-08 23:40:18 +02:00
MartinBraquet
35e9264017 Show profiles number, not users number 2025-10-08 23:39:42 +02:00
MartinBraquet
02d33c8f83 Rename mock user 2025-10-08 20:38:31 +02:00
MartinBraquet
f229ebc3a8 Add email confirmation 2025-10-08 20:38:20 +02:00
MartinBraquet
0062351f6d Add welcome email 2025-10-08 20:38:09 +02:00
MartinBraquet
e86f6798ec Fix bullet 2025-10-08 20:37:35 +02:00
MartinBraquet
4f53f7136b Add members 2025-10-08 20:35:57 +02:00
MartinBraquet
d80b982dde Simplify tab title 2025-10-08 17:32:19 +02:00
MartinBraquet
24788aa9af Add optional Garamond font 2025-10-08 14:11:51 +02:00
MartinBraquet
9ffae658df Clean 2025-10-08 11:58:58 +02:00
MartinBraquet
82ad573cac Add stoat link 2025-10-08 11:58:52 +02:00
MartinBraquet
36bf7ad65b Fix 2025-10-07 22:53:37 +02:00
182 changed files with 3838 additions and 1624 deletions

1
.gitignore vendored
View File

@@ -84,4 +84,5 @@ email-preview
*.tfstate
*.tfstate.backup
*.terraform
/backups/firebase/auth/data/
/backups/firebase/storage/data/

View File

@@ -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 its 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 dont 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, just use your preferred option:
- Ask or DM an admin on Discord
- 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 dont 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
@@ -58,7 +75,7 @@ Everything is open to anyone for collaboration, but the following ones are parti
- [ ] 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,6 +152,16 @@ 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!
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 features.
### Submission

View File

@@ -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:

View File

@@ -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
# }
}
}

View File

@@ -18,7 +18,7 @@
"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"

View File

@@ -50,9 +50,15 @@ 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";
const allowCorsUnrestricted: RequestHandler = cors({})
@@ -108,7 +114,7 @@ swaggerDocument.info = {
version: "1.0.0",
contact: {
name: "Compass",
email: "compass.meet.info@gmail.com",
email: "hello@compassmeet.com",
url: "https://compassmeet.com"
}
};
@@ -153,6 +159,9 @@ const handlers: { [k in APIPath]: APIHandler<k> } = {
'create-comment': createComment,
'hide-comment': hideComment,
'create-compatibility-question': createCompatibilityQuestion,
'create-vote': createVote,
'vote': vote,
'contact': contact,
'compatible-profiles': getCompatibleProfilesHandler,
'search-location': searchLocation,
'search-near-city': searchNearCity,
@@ -164,6 +173,8 @@ const handlers: { [k in APIPath]: APIHandler<k> } = {
'get-channel-messages': getChannelMessages,
'get-channel-seen-time': getLastSeenChannelTime,
'set-channel-seen-time': setChannelLastSeenTime,
'get-messages-count': getMessagesCount,
'set-last-online-time': setLastOnlineTime,
}
Object.entries(handlers).forEach(([path, handler]) => {
@@ -191,7 +202,7 @@ Object.entries(handlers).forEach(([path, handler]) => {
}
})
// console.log('COMPASS_API_KEY:', process.env.COMPASS_API_KEY)
// console.debug('COMPASS_API_KEY:', process.env.COMPASS_API_KEY)
// Internal Endpoints
app.post(pathWithPrefix("/internal/send-search-notifications"),
@@ -206,6 +217,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"});
}
}

View 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,
}
}

View File

@@ -8,6 +8,7 @@ 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)
}
}

View File

@@ -7,12 +7,13 @@ 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";
export const createUser: APIHandler<'create-user'> = async (
props,
@@ -126,7 +127,12 @@ 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)
}
}

View 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 }
}

View File

@@ -26,6 +26,8 @@ export const deleteMe: APIHandler<'me/delete'> = async (body, auth) => {
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])
await pg.none('DELETE FROM bookmarked_searches WHERE creator_id = $1', [userId])
await pg.none('DELETE FROM love_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 +37,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)
}

View File

@@ -16,7 +16,7 @@ export const getCompatibilityQuestions: APIHandler<
> = async (_props, _auth) => {
const pg = createSupabaseDirectClient()
const dbQuestions = await pg.manyOrNone<
const questions = await pg.manyOrNone<
Row<'love_questions'> & { answer_count: number; score: number }
>(
`SELECT
@@ -31,15 +31,15 @@ export const getCompatibilityQuestions: APIHandler<
love_questions.answer_type = 'compatibility_multiple_choice'
GROUP BY
love_questions.id
ORDER BY
score DESC
ORDER BY
love_questions.importance_score
`,
[]
)
const questions = shuffle(dbQuestions)
// const questions = shuffle(dbQuestions)
// console.log(
// console.debug(
// 'got questions',
// questions.map((q) => q.question + ' ' + q.score)
// )

View 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,
}
}

View File

@@ -1,10 +1,10 @@
import {type APIHandler} from 'api/helpers/endpoint'
import {convertRow} from 'shared/love/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,
@@ -16,20 +16,27 @@ export type profileQueryType = {
pref_age_min?: number | undefined,
pref_age_max?: number | undefined,
pref_relation_styles?: String[] | undefined,
pref_romantic_styles?: 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,
@@ -39,16 +46,23 @@ export const loadProfiles = async (props: profileQueryType) => {
pref_age_min,
pref_age_max,
pref_relation_styles,
pref_romantic_styles,
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)
@@ -69,6 +83,8 @@ export const loadProfiles = async (props: profileQueryType) => {
(!pref_age_max || (l.age ?? MIN_INT) <= pref_age_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) &&
(!wants_kids_strength ||
wants_kids_strength == -1 ||
(wants_kids_strength >= 2
@@ -81,23 +97,34 @@ export const loadProfiles = async (props: profileQueryType) => {
(!is_smoker || 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,7 +133,7 @@ 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}
)),
@@ -124,7 +151,13 @@ export const loadProfiles = async (props: profileQueryType) => {
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}
),
!!wants_kids_strength &&
@@ -144,21 +177,40 @@ export const loadProfiles = async (props: profileQueryType) => {
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)
}

View File

@@ -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,

View File

@@ -163,7 +163,7 @@ const notifyOtherUserInChannelIfInactive = async (
// TODO: notification only for active user
const otherUser = await getUser(otherUserId.user_id)
console.log('otherUser:', otherUser)
console.debug('otherUser:', otherUser)
if (!otherUser) return
await createNewMessageNotification(creator, otherUser, channelId)
@@ -175,7 +175,7 @@ const createNewMessageNotification = async (
channelId: number
) => {
const privateUser = await getPrivateUser(toUser.id)
console.log('privateUser:', privateUser)
console.debug('privateUser:', privateUser)
if (!privateUser) return
await sendNewMessageEmail(privateUser, fromUser, toUser, channelId)
}

View File

@@ -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,
}
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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'}

View File

@@ -0,0 +1,22 @@
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
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';
`,
[auth.uid]
)
// console.log('setLastOnline')
}

View File

@@ -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
View 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)
}
}

View File

@@ -1,5 +1,5 @@
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'
@@ -8,8 +8,9 @@ import {getProfile} from 'shared/love/supabase'
import { render } from "@react-email/render"
import {MatchesType} from "common/love/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,

View File

@@ -1,9 +1,9 @@
import { ProfileRow } from 'common/love/profile'
import type { User } from 'common/user'
import {ProfileRow} from 'common/love/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',
@@ -31,14 +31,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',
@@ -78,6 +78,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 +102,8 @@ export const sinclairProfile: ProfileRow = {
},
],
},
bio_text: 'the futa in futarchy',
bio_tsv: 'the futa in futarchy',
age: 25,
}
@@ -129,14 +132,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',
@@ -173,6 +176,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 +206,7 @@ export const jamesProfile: ProfileRow = {
},
],
},
bio_text: 'the futa in futarchy',
bio_tsv: 'the futa in futarchy',
age: 32,
}

View File

@@ -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
}

View File

@@ -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))
}

View File

@@ -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',

View File

@@ -2,7 +2,7 @@ import {Body, Button, Container, Head, Html, Preview, Section, Text,} from '@rea
import {DOMAIN} from 'common/envs/constants'
import {type ProfileRow} from 'common/love/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 {
@@ -70,7 +70,7 @@ export const NewMatchEmail = ({
}
NewMatchEmail.PreviewProps = {
onUser: sinclairUser,
onUser: mockUser,
matchedWithUser: jamesUser,
matchedProfile: jamesProfile,
email: 'someone@gmail.com',

View File

@@ -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 {jamesProfile, jamesUser, mockUser,} from './functions/mock'
import {DOMAIN} from 'common/envs/constants'
import {button, container, content, Footer, imageContainer, main, paragraph} from "email/utils";
@@ -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',

View File

@@ -1,6 +1,6 @@
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";
@@ -140,7 +140,7 @@ const matchSamples = [
]
NewSearchAlertsEmail.PreviewProps = {
toUser: sinclairUser,
toUser: mockUser,
email: 'someone@gmail.com',
unsubscribeUrl: 'https://compassmeet.com/unsubscribe',
matches: matchSamples,

View File

@@ -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>

View 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

View File

@@ -5,7 +5,7 @@
"rules": "storage.rules"
},
{
"bucket": "compass-130ba-private.firebasestorage.app",
"bucket": "compass-130ba-private",
"rules": "private-storage.rules"
}
]

View File

@@ -7,4 +7,4 @@ service firebase.storage {
allow write: if request.auth.uid == userId && request.resource.size <= 20 * 1024 * 1024; // 20MB
}
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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

View File

@@ -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))
})
}

View File

@@ -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`
)

View File

@@ -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)
}
})

View File

@@ -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}`);
}

View File

@@ -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`
}
}

View File

@@ -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(' & ') + ':*'

View File

@@ -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()
}

View File

@@ -14,10 +14,14 @@ 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'

View File

@@ -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) + ' ' : ''

View File

@@ -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()

View File

@@ -64,7 +64,7 @@ const newClient = (
...settings,
}
// console.log(config)
// console.debug(config)
return pgp(config)
}

View File

@@ -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.')

View File

@@ -0,0 +1,14 @@
create table if not exists
contact (
id text default uuid_generate_v4 () not null,
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;

View File

@@ -9,6 +9,7 @@ END$$;
CREATE TABLE IF NOT EXISTS profiles (
age INTEGER NULL,
bio JSONB,
bio_length integer null,
born_in_location TEXT,
city TEXT NOT NULL,
city_latitude NUMERIC(9, 6),
@@ -27,7 +28,6 @@ CREATE TABLE IF NOT EXISTS profiles (
id BIGINT GENERATED ALWAYS AS IDENTITY NOT NULL,
is_smoker BOOLEAN,
is_vegetarian_or_vegan BOOLEAN,
last_online_time TIMESTAMPTZ DEFAULT now() NOT NULL,
last_modification_time TIMESTAMPTZ DEFAULT now() NOT NULL,
looking_for_matches BOOLEAN DEFAULT TRUE NOT NULL,
messaging_status TEXT DEFAULT 'open'::TEXT NOT NULL,
@@ -40,6 +40,7 @@ CREATE TABLE IF NOT EXISTS profiles (
pref_age_min INTEGER NULL,
pref_gender TEXT[] NOT NULL,
pref_relation_styles TEXT[] NOT NULL,
pref_romantic_styles TEXT[],
referred_by_username TEXT,
region_code TEXT,
religious_belief_strength INTEGER,
@@ -58,10 +59,8 @@ ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
-- Policies
DROP POLICY IF EXISTS "public read" ON profiles;
CREATE POLICY "public read" ON profiles
FOR SELECT
USING (true);
FOR SELECT USING (true);
DROP POLICY IF EXISTS "self update" ON profiles;
@@ -79,22 +78,29 @@ CREATE UNIQUE INDEX unique_user_id ON public.profiles USING btree (user_id);
CREATE INDEX IF NOT EXISTS idx_profiles_last_mod_24h
ON public.profiles USING btree (last_modification_time);
CREATE INDEX IF NOT EXISTS idx_profiles_bio_length
ON profiles (bio_length);
-- Fastest general-purpose index
DROP INDEX IF EXISTS profiles_lat_lon_idx;
CREATE INDEX profiles_lat_lon_idx ON profiles (city_latitude, city_longitude);
-- Optional additional index for large tables / clustered inserts
DROP INDEX IF EXISTS profiles_lat_lon_brin_idx;
CREATE INDEX profiles_lat_lon_brin_idx ON profiles USING BRIN (city_latitude, city_longitude) WITH (pages_per_range = 32);
-- Functions and Triggers
CREATE
OR REPLACE FUNCTION update_last_modification_time()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.last_online_time IS DISTINCT FROM OLD.last_online_time AND row(NEW.*) = row(OLD.*) THEN
-- Only last_online_time changed, do nothing
RETURN NEW;
END IF;
-- Some other column changed
NEW.last_modification_time = now();
RETURN NEW;
NEW.last_modification_time = now();
RETURN NEW;
END;
$$
LANGUAGE plpgsql;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trigger_update_last_mod_time
BEFORE UPDATE
@@ -110,25 +116,29 @@ CREATE INDEX profiles_bio_trgm_idx
--- bio_text
-- ALTER TABLE profiles ADD COLUMN bio_text tsvector;
--
-- CREATE OR REPLACE FUNCTION profiles_bio_tsvector_update()
-- RETURNS trigger AS $$
-- BEGIN
-- new.bio_text := to_tsvector(
-- 'english',
-- (
-- SELECT string_agg(trim(both '"' from x::text), ' ')
-- FROM jsonb_path_query(new.bio, '$.**.text'::jsonpath) AS x
-- )
-- );
-- RETURN new;
-- END;
-- $$ LANGUAGE plpgsql;
--
-- CREATE TRIGGER profiles_bio_tsvector_trigger
-- BEFORE INSERT OR UPDATE OF bio ON profiles
-- FOR EACH ROW EXECUTE FUNCTION profiles_bio_tsvector_update();
--
-- create index on profiles using gin(bio_text);
ALTER TABLE profiles ADD COLUMN bio_text TEXT;
UPDATE profiles
SET bio_text = (
SELECT string_agg(DISTINCT trim(both '"' from value::text), ' ')
FROM jsonb_path_query(bio, '$.**.text') AS t(value)
);
ALTER TABLE profiles ADD COLUMN bio_tsv tsvector
GENERATED ALWAYS AS (to_tsvector('english', coalesce(bio_text, ''))) STORED;
CREATE INDEX profiles_bio_tsv_idx ON profiles USING GIN (bio_tsv);
CREATE OR REPLACE FUNCTION update_bio_text()
RETURNS trigger AS $$
BEGIN
NEW.bio_text := (
SELECT string_agg(DISTINCT trim(both '"' from value::text), ' ')
FROM jsonb_path_query(NEW.bio, '$.**.text') AS t(value)
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_update_bio_text
BEFORE INSERT OR UPDATE OF bio ON profiles
FOR EACH ROW EXECUTE FUNCTION update_bio_text();

View File

@@ -0,0 +1,16 @@
CREATE TABLE user_activity
(
user_id TEXT PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE,
last_online_time TIMESTAMPTZ NOT NULL
);
-- Row Level Security
ALTER TABLE user_activity ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "public read" ON user_activity;
CREATE POLICY "public read" ON user_activity
FOR SELECT USING (true);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_user_activity_last_online_time
ON user_activity (last_online_time DESC);

View File

@@ -0,0 +1,94 @@
CREATE TABLE IF NOT EXISTS vote_results (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
user_id TEXT NOT NULL,
vote_id BIGINT NOT NULL,
choice smallint NOT NULL CHECK (choice IN (-1, 0, 1)),
priority smallint NOT NULL CHECK (priority IN (0, 1, 2, 3)),
UNIQUE (user_id, vote_id) -- ensures one vote per user
);
-- Foreign Keys
alter table vote_results
add constraint vote_results_user_id_fkey foreign key (user_id) references users (id);
alter table vote_results
add constraint vote_results_vote_id_fkey foreign key (vote_id) references votes (id);
-- Row Level Security
ALTER TABLE vote_results ENABLE ROW LEVEL SECURITY;
-- Policies
DROP POLICY IF EXISTS "public read" ON vote_results;
CREATE POLICY "public read" ON vote_results
FOR ALL USING (true);
-- Indexes
DROP INDEX IF EXISTS user_id_idx;
CREATE INDEX user_id_idx ON vote_results (user_id);
DROP INDEX IF EXISTS vote_id_idx;
CREATE INDEX vote_id_idx ON vote_results (vote_id);
DROP INDEX IF EXISTS idx_vote_results_vote_choice;
CREATE INDEX idx_vote_results_vote_choice ON vote_results (vote_id, choice);
DROP INDEX IF EXISTS idx_vote_results_vote_choice_priority;
CREATE INDEX idx_vote_results_vote_choice_priority ON vote_results (vote_id, choice, priority);
DROP INDEX IF EXISTS idx_votes_created_time;
CREATE INDEX idx_votes_created_time ON votes (created_time DESC);
drop function if exists get_votes_with_results;
create or replace function get_votes_with_results(order_by text default 'recent')
returns table (
id BIGINT,
title text,
description jsonb,
created_time timestamptz,
creator_id TEXT,
is_anonymous boolean,
votes_for int,
votes_against int,
votes_abstain int,
priority int
)
as $$
with results as (
SELECT
v.id,
v.title,
v.description,
v.created_time,
v.creator_id,
v.is_anonymous,
COALESCE(SUM(CASE WHEN r.choice = 1 THEN 1 ELSE 0 END), 0) AS votes_for,
COALESCE(SUM(CASE WHEN r.choice = -1 THEN 1 ELSE 0 END), 0) AS votes_against,
COALESCE(SUM(CASE WHEN r.choice = 0 THEN 1 ELSE 0 END), 0) AS votes_abstain,
COALESCE(SUM(r.priority), 0)::float / GREATEST(COALESCE(SUM(CASE WHEN r.choice = 1 THEN 1 ELSE 0 END), 1), 1) * 100 / 3 AS priority
FROM votes v
LEFT JOIN vote_results r ON v.id = r.vote_id
GROUP BY v.id
)
SELECT
id,
title,
description,
created_time,
creator_id,
is_anonymous,
votes_for,
votes_against,
votes_abstain,
priority
FROM results
ORDER BY
CASE WHEN order_by = 'recent' THEN created_time END DESC,
CASE WHEN order_by = 'mostVoted' THEN (votes_for + votes_against + votes_abstain) END DESC,
CASE WHEN order_by = 'mostVoted' THEN created_time END DESC,
CASE WHEN order_by = 'priority' THEN priority END DESC,
CASE WHEN order_by = 'priority' THEN created_time END DESC;
$$ language sql stable;

View File

@@ -0,0 +1,27 @@
CREATE TABLE IF NOT EXISTS votes (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
creator_id TEXT NOT NULL,
title TEXT NOT NULL,
is_anonymous BOOLEAN NOT NULL,
description JSONB
);
-- Foreign Keys
alter table votes
add constraint votes_creator_id_fkey foreign key (creator_id) references users (id);
-- Row Level Security
ALTER TABLE votes ENABLE ROW LEVEL SECURITY;
-- Policies
DROP POLICY IF EXISTS "public read" ON votes;
CREATE POLICY "public read" ON votes
FOR ALL USING (true);
-- Indexes
DROP INDEX IF EXISTS creator_id_idx;
CREATE INDEX creator_id_idx ON votes (creator_id);
DROP INDEX IF EXISTS idx_votes_created_time;
CREATE INDEX idx_votes_created_time ON votes (created_time DESC);

View File

@@ -1,18 +0,0 @@
#!/bin/bash
set -e
cd "$(dirname "$0")"
PROJECT=compass-130ba
TIMESTAMP=$(date +"%F_%H-%M-%S")
DESTINATION=./data/$TIMESTAMP
mkdir -p $DESTINATION
gsutil -m cp -r gs://$PROJECT.firebasestorage.app $DESTINATION
echo Backup of Firebase Storage done

View File

@@ -1,170 +0,0 @@
locals {
project = "compass-130ba"
region = "us-west1"
zone = "us-west1-b"
service_name = "backup"
machine_type = "e2-micro"
}
variable "env" {
description = "Environment (env or prod)"
type = string
default = "prod"
}
provider "google" {
project = local.project
region = local.region
zone = local.zone
}
# Service account for the VM (needs Secret Manager + Storage access)
resource "google_service_account" "backup_vm_sa" {
account_id = "backup-vm-sa"
display_name = "Backup VM Service Account"
}
# IAM roles
resource "google_project_iam_member" "backup_sa_secret_manager" {
project = "compass-130ba"
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.backup_vm_sa.email}"
}
resource "google_project_iam_member" "backup_sa_storage_admin" {
project = "compass-130ba"
role = "roles/storage.objectAdmin"
member = "serviceAccount:${google_service_account.backup_vm_sa.email}"
}
# Minimal VM
resource "google_compute_instance" "backup_vm" {
name = "supabase-backup-vm"
machine_type = local.machine_type
zone = local.zone
boot_disk {
initialize_params {
image = "debian-11-bullseye-v20250915"
size = 20
}
}
network_interface {
network = "default"
access_config {}
}
service_account {
email = google_service_account.backup_vm_sa.email
scopes = ["https://www.googleapis.com/auth/cloud-platform"]
}
metadata_startup_script = <<-EOT
#!/bin/bash
apt-get update
apt-get install -y postgresql-client cron wget curl unzip
# Add PostgreSQL repo
sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list'
wget -qO - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add -
sudo apt-get update
sudo apt-get install -y postgresql-client-17
sudo apt-get install -y mailutils
# Create backup directory
mkdir -p /home/martin/supabase_backups
chown -R martin:martin /home/martin
# Example backup script
cat <<'EOF' > /home/martin/backup.sh
#!/bin/bash
# Backup Supabase database and upload to Google Cloud Storage daily, retaining backups for 30 days.
set -e
cd $(dirname "$0")
export ENV=prod
if [ "$ENV" = "prod" ]; then
export PGHOST="aws-1-us-west-1.pooler.supabase.com"
elif [ "$ENV" = "dev" ]; then
export PGHOST="db.zbspxezubpzxmuxciurg.supabase.co"
else
echo "Error: ENV must be 'prod' or 'dev'" >&2
exit 1
fi
# Config
PGPORT="5432"
PGUSER="postgres.ltzepxnhhnrnvovqblfr"
PGDATABASE="postgres"
# Retrieve password from Secret Manager
PGPASSWORD=$(gcloud secrets versions access latest --secret="SUPABASE_DB_PASSWORD")
BUCKET_NAME="gs://compass-130ba.firebasestorage.app/backups/supabase"
BACKUP_DIR="/tmp/supabase_backups"
RETENTION_DAYS=30
mkdir -p "$BACKUP_DIR"
TIMESTAMP=$(date +"%F_%H-%M-%S")
BACKUP_FILE="$BACKUP_DIR/$TIMESTAMP.sql"
export PGPASSWORD
pg_dump -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -F c -b -v -f "$BACKUP_FILE"
if [ $? -ne 0 ]; then
echo "Backup failed!"
exit 1
fi
echo "Backup successful: $BACKUP_FILE"
# UPLOAD TO GCS
echo "Uploading backup to GCS..."
gsutil cp "$BACKUP_FILE" "$BUCKET_NAME/"
# LOCAL RETENTION
LOCAL_RETENTION_DAYS=7
echo "Removing local backups older than $LOCAL_RETENTION_DAYS days..."
find "$BACKUP_DIR" -type f -mtime +$LOCAL_RETENTION_DAYS -delete
# GCS RETENTION
echo "Cleaning old backups from GCS..."
gsutil ls "$BUCKET_NAME/" | while read file; do
filename=$(basename "$file")
# Extract timestamp from filename
file_date=$(echo "$filename" | sed -E 's/(.*)\.sql/\1/')
# Convert to seconds since epoch
file_date="2025-09-24_13-00-54"
date_part=${file_date%_*} # "2025-09-24"
time_part=${file_date#*_} # "13-00-54"
time_part=${time_part//-/:} # "13:00:54"
file_ts=$(date -d "$date_part $time_part" +%s)
# echo "$file, $filename, $file_date, $file_ts"
if [ -z "$file_ts" ]; then
continue
fi
now=$(date +%s)
diff_days=$(( (now - file_ts) / 86400 ))
echo "File: $filename is $diff_days days old."
if [ "$diff_days" -gt "$RETENTION_DAYS" ]; then
echo "Deleting $file from GCS..."
gsutil rm "$file"
fi
done
echo "Backup and retention process completed at $(date)."
EOF
chmod +x /home/martin/backup.sh
# Add cron job: daily at 2AM
( crontab -l 2>/dev/null; echo '0 2 * * * /home/martin/backup.sh >> /home/martin/backup.log 2>&1 || curl -H "Content-Type: application/json" -X POST -d "{\"content\": \" Backup FAILED on $(hostname) at $(date)\"}" https://discord.com/api/webhooks/1420405275340574873/XgF5pgHABvvWT2fyWASBs3VhAF7Zy11rCH2BkI_RBxH1Xd5duWxGtukrc1cPy1ZucNwx' ) | crontab -
# tail -f /home/martin/backup.log
}

View File

@@ -1,18 +0,0 @@
#!/bin/bash
set -e
cd $(dirname "$0")
#gcloud compute firewall-rules create allow-iap-ssh \
# --direction=INGRESS \
# --action=ALLOW \
# --rules=tcp:22 \
# --source-ranges=35.235.240.0/20 \
# --target-tags=iap-ssh
# gcloud compute instances add-tags "supabase-backup-vm" --tags=iap-ssh --zone="us-west1-b"
gcloud compute ssh --zone "us-west1-b" "supabase-backup-vm" --project "compass-130ba" --tunnel-through-iap
# sudo crontab -u backup -l

View File

@@ -1,81 +0,0 @@
#!/bin/bash
# Backup Supabase database and upload to Google Cloud Storage daily, retaining backups for 30 days.
set -e
cd $(dirname "$0")
export ENV=prod
if [ "$ENV" = "prod" ]; then
export PGHOST="aws-1-us-west-1.pooler.supabase.com"
elif [ "$ENV" = "dev" ]; then
export PGHOST="db.zbspxezubpzxmuxciurg.supabase.co"
else
echo "Error: ENV must be 'prod' or 'dev'" >&2
exit 1
fi
# Config
PGPORT="5432"
PGUSER="postgres.ltzepxnhhnrnvovqblfr"
PGDATABASE="postgres"
# Retrieve password from Secret Manager
PGPASSWORD=$(gcloud secrets versions access latest --secret="SUPABASE_DB_PASSWORD")
BUCKET_NAME="gs://compass-130ba.firebasestorage.app/backups/supabase"
BACKUP_DIR="/tmp/supabase_backups"
RETENTION_DAYS=30
mkdir -p "$BACKUP_DIR"
TIMESTAMP=$(date +"%F_%H-%M-%S")
BACKUP_FILE="$BACKUP_DIR/$TIMESTAMP.sql"
export PGPASSWORD
pg_dump -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -F c -b -v -f "$BACKUP_FILE"
if [ $? -ne 0 ]; then
echo "Backup failed!"
exit 1
fi
echo "Backup successful: $BACKUP_FILE"
# UPLOAD TO GCS
echo "Uploading backup to GCS..."
gsutil cp "$BACKUP_FILE" "$BUCKET_NAME/"
# LOCAL RETENTION
LOCAL_RETENTION_DAYS=7
echo "Removing local backups older than $LOCAL_RETENTION_DAYS days..."
find "$BACKUP_DIR" -type f -mtime +$LOCAL_RETENTION_DAYS -delete
# GCS RETENTION
echo "Cleaning old backups from GCS..."
gsutil ls "$BUCKET_NAME/" | while read file; do
filename=$(basename "$file")
# Extract timestamp from filename
file_date=$(echo "$filename" | sed -E 's/(.*)\.sql/\1/')
# Convert to seconds since epoch
file_date="2025-09-24_13-00-54"
date_part=${file_date%_*} # "2025-09-24"
time_part=${file_date#*_} # "13-00-54"
time_part=${time_part//-/:} # "13:00:54"
file_ts=$(date -d "$date_part $time_part" +%s)
# echo "$file, $filename, $file_date, $file_ts"
if [ -z "$file_ts" ]; then
continue
fi
now=$(date +%s)
diff_days=$(( (now - file_ts) / 86400 ))
echo "File: $filename is $diff_days days old."
if [ "$diff_days" -gt "$RETENTION_DAYS" ]; then
echo "Deleting $file from GCS..."
gsutil rm "$file"
fi
done
echo "Backup and retention process completed at $(date)."

View File

@@ -4,19 +4,19 @@ import {
baseProfilesSchema,
arraybeSchema,
} from 'common/api/zod-types'
import { PrivateChatMessage } from 'common/chat-message'
import { CompatibilityScore } from 'common/love/compatibility-score'
import { MAX_COMPATIBILITY_QUESTION_LENGTH } from 'common/love/constants'
import { Profile, ProfileRow } from 'common/love/profile'
import { Row } from 'common/supabase/utils'
import { PrivateUser, User } from 'common/user'
import { z } from 'zod'
import { LikeData, ShipData } from './love-types'
import { DisplayUser, FullUser } from './user-types'
import { PrivateMessageChannel } from 'common/supabase/private-messages'
import { Notification } from 'common/notifications'
import { arrify } from 'common/util/array'
import { notification_preference } from 'common/user-notification-preferences'
import {PrivateChatMessage} from 'common/chat-message'
import {CompatibilityScore} from 'common/love/compatibility-score'
import {MAX_COMPATIBILITY_QUESTION_LENGTH} from 'common/love/constants'
import {Profile, ProfileRow} from 'common/love/profile'
import {Row} from 'common/supabase/utils'
import {PrivateUser, User} from 'common/user'
import {z} from 'zod'
import {LikeData, ShipData} from './love-types'
import {DisplayUser, FullUser} from './user-types'
import {PrivateMessageChannel} from 'common/supabase/private-messages'
import {Notification} from 'common/notifications'
import {arrify} from 'common/util/array'
import {notification_preference} from 'common/user-notification-preferences'
// mqp: very unscientific, just balancing our willingness to accept load
// with user willingness to put up with stale data
@@ -28,6 +28,8 @@ type APIGenericSchema = {
method: 'GET' | 'POST' | 'PUT'
// whether the endpoint requires authentication
authed: boolean
// whether the endpoint requires authentication
rateLimited?: boolean
// zod schema for the request body (or for params for GET requests)
props: z.ZodType
// note this has to be JSON serializable
@@ -42,33 +44,39 @@ export const API = (_apiTypeCheck = {
health: {
method: 'GET',
authed: false,
rateLimited: false,
props: z.object({}),
returns: {} as { message: 'Server is working.'; uid?: string },
},
'get-supabase-token': {
method: 'GET',
authed: true,
rateLimited: false,
props: z.object({}),
returns: {} as { jwt: string },
},
'mark-all-notifs-read': {
method: 'POST',
authed: true,
rateLimited: false,
props: z.object({}),
},
'user/by-id/:id/block': {
method: 'POST',
authed: true,
props: z.object({ id: z.string() }).strict(),
rateLimited: false,
props: z.object({id: z.string()}).strict(),
},
'user/by-id/:id/unblock': {
method: 'POST',
authed: true,
props: z.object({ id: z.string() }).strict(),
rateLimited: false,
props: z.object({id: z.string()}).strict(),
},
'ban-user': {
method: 'POST',
authed: true,
rateLimited: false,
props: z
.object({
userId: z.string(),
@@ -80,6 +88,7 @@ export const API = (_apiTypeCheck = {
// TODO rest
method: 'POST',
authed: true,
rateLimited: true,
returns: {} as { user: User; privateUser: PrivateUser },
props: z
.object({
@@ -91,12 +100,14 @@ export const API = (_apiTypeCheck = {
'create-profile': {
method: 'POST',
authed: true,
rateLimited: true,
returns: {} as Row<'profiles'>,
props: baseProfilesSchema,
},
report: {
method: 'POST',
authed: true,
rateLimited: true,
props: z
.object({
contentOwnerId: z.string(),
@@ -112,6 +123,7 @@ export const API = (_apiTypeCheck = {
me: {
method: 'GET',
authed: true,
rateLimited: false,
cache: DEFAULT_CACHE_STRATEGY,
props: z.object({}),
returns: {} as FullUser,
@@ -119,6 +131,7 @@ export const API = (_apiTypeCheck = {
'me/update': {
method: 'POST',
authed: true,
rateLimited: true,
props: z.object({
name: z.string().trim().min(1).optional(),
username: z.string().trim().min(1).optional(),
@@ -146,12 +159,14 @@ export const API = (_apiTypeCheck = {
'update-profile': {
method: 'POST',
authed: true,
rateLimited: true,
props: combinedLoveUsersSchema.partial(),
returns: {} as ProfileRow,
},
'update-notif-settings': {
method: 'POST',
authed: true,
rateLimited: false,
props: z.object({
type: z.string() as z.ZodType<notification_preference>,
medium: z.enum(['email', 'browser', 'mobile']),
@@ -161,6 +176,7 @@ export const API = (_apiTypeCheck = {
'me/delete': {
method: 'POST',
authed: true,
rateLimited: true,
props: z.object({
username: z.string(), // just so you're sure
}),
@@ -168,54 +184,61 @@ export const API = (_apiTypeCheck = {
'me/private': {
method: 'GET',
authed: true,
rateLimited: false,
props: z.object({}),
returns: {} as PrivateUser,
},
'user/:username': {
method: 'GET',
authed: false,
rateLimited: false,
cache: DEFAULT_CACHE_STRATEGY,
returns: {} as FullUser,
props: z.object({ username: z.string() }).strict(),
props: z.object({username: z.string()}).strict(),
},
'user/:username/lite': {
method: 'GET',
authed: false,
rateLimited: false,
cache: DEFAULT_CACHE_STRATEGY,
returns: {} as DisplayUser,
props: z.object({ username: z.string() }).strict(),
props: z.object({username: z.string()}).strict(),
},
'user/by-id/:id': {
method: 'GET',
authed: false,
rateLimited: false,
cache: DEFAULT_CACHE_STRATEGY,
returns: {} as FullUser,
props: z.object({ id: z.string() }).strict(),
props: z.object({id: z.string()}).strict(),
},
'user/by-id/:id/lite': {
method: 'GET',
authed: false,
rateLimited: false,
cache: DEFAULT_CACHE_STRATEGY,
returns: {} as DisplayUser,
props: z.object({ id: z.string() }).strict(),
props: z.object({id: z.string()}).strict(),
},
'search-users': {
method: 'GET',
authed: false,
authed: true,
rateLimited: true,
cache: DEFAULT_CACHE_STRATEGY,
returns: [] as FullUser[],
props: z
.object({
term: z.string(),
limit: z.coerce.number().gte(0).lte(1000).default(500),
limit: z.coerce.number().gte(0).lte(20).default(500),
page: z.coerce.number().gte(0).default(0),
})
.strict(),
},
'compatible-profiles': {
method: 'GET',
authed: false,
props: z.object({ userId: z.string() }),
authed: true,
rateLimited: true,
props: z.object({userId: z.string()}),
returns: {} as {
profile: Profile
compatibleProfiles: Profile[]
@@ -227,7 +250,8 @@ export const API = (_apiTypeCheck = {
'remove-pinned-photo': {
method: 'POST',
authed: true,
returns: { success: true },
rateLimited: true,
returns: {success: true},
props: z
.object({
userId: z.string(),
@@ -236,7 +260,8 @@ export const API = (_apiTypeCheck = {
},
'get-compatibility-questions': {
method: 'GET',
authed: false,
authed: true,
rateLimited: false,
props: z.object({}),
returns: {} as {
status: 'success'
@@ -249,6 +274,7 @@ export const API = (_apiTypeCheck = {
'like-profile': {
method: 'POST',
authed: true,
rateLimited: true,
props: z.object({
targetUserId: z.string(),
remove: z.boolean().optional(),
@@ -260,6 +286,7 @@ export const API = (_apiTypeCheck = {
'ship-profiles': {
method: 'POST',
authed: true,
rateLimited: true,
props: z.object({
targetUserId1: z.string(),
targetUserId2: z.string(),
@@ -271,7 +298,8 @@ export const API = (_apiTypeCheck = {
},
'get-likes-and-ships': {
method: 'GET',
authed: false,
authed: true,
rateLimited: true,
props: z
.object({
userId: z.string(),
@@ -287,6 +315,7 @@ export const API = (_apiTypeCheck = {
'has-free-like': {
method: 'GET',
authed: true,
rateLimited: true,
props: z.object({}).strict(),
returns: {} as {
status: 'success'
@@ -296,6 +325,7 @@ export const API = (_apiTypeCheck = {
'star-profile': {
method: 'POST',
authed: true,
rateLimited: true,
props: z.object({
targetUserId: z.string(),
remove: z.boolean().optional(),
@@ -306,10 +336,11 @@ export const API = (_apiTypeCheck = {
},
'get-profiles': {
method: 'GET',
authed: false,
authed: true,
rateLimited: true,
props: z
.object({
limit: z.coerce.number().optional().default(20),
limit: z.coerce.number().gt(0).lte(20).optional().default(20),
after: z.string().optional(),
// Search and filter parameters
name: z.string().optional(),
@@ -318,10 +349,15 @@ export const API = (_apiTypeCheck = {
pref_age_min: z.coerce.number().optional(),
pref_age_max: z.coerce.number().optional(),
pref_relation_styles: arraybeSchema.optional(),
pref_romantic_styles: arraybeSchema.optional(),
wants_kids_strength: z.coerce.number().optional(),
has_kids: z.coerce.number().optional(),
is_smoker: z.coerce.boolean().optional(),
shortBio: z.coerce.boolean().optional(),
geodbCityIds: arraybeSchema.optional(),
lat: z.coerce.number().optional(),
lon: z.coerce.number().optional(),
radius: z.coerce.number().optional(),
compatibleWithUserId: z.string().optional(),
orderBy: z
.enum(['last_online_time', 'created_time', 'compatibility_score'])
@@ -336,8 +372,9 @@ export const API = (_apiTypeCheck = {
},
'get-profile-answers': {
method: 'GET',
authed: false,
props: z.object({ userId: z.string() }).strict(),
authed: true,
rateLimited: true,
props: z.object({userId: z.string()}).strict(),
returns: {} as {
status: 'success'
answers: Row<'love_compatibility_answers'>[]
@@ -346,6 +383,7 @@ export const API = (_apiTypeCheck = {
'create-comment': {
method: 'POST',
authed: true,
rateLimited: true,
props: z.object({
userId: z.string(),
content: contentSchema,
@@ -356,6 +394,7 @@ export const API = (_apiTypeCheck = {
'hide-comment': {
method: 'POST',
authed: true,
rateLimited: true,
props: z.object({
commentId: z.string(),
hide: z.boolean(),
@@ -365,6 +404,7 @@ export const API = (_apiTypeCheck = {
'get-channel-memberships': {
method: 'GET',
authed: true,
rateLimited: false,
props: z.object({
channelId: z.coerce.number().optional(),
createdTime: z.string().optional(),
@@ -379,6 +419,7 @@ export const API = (_apiTypeCheck = {
'get-channel-messages': {
method: 'GET',
authed: true,
rateLimited: false,
props: z.object({
channelId: z.coerce.number(),
limit: z.coerce.number(),
@@ -389,6 +430,7 @@ export const API = (_apiTypeCheck = {
'get-channel-seen-time': {
method: 'GET',
authed: true,
rateLimited: false,
props: z.object({
channelIds: z
.array(z.coerce.number())
@@ -400,13 +442,21 @@ export const API = (_apiTypeCheck = {
'set-channel-seen-time': {
method: 'POST',
authed: true,
rateLimited: false,
props: z.object({
channelId: z.coerce.number(),
}),
},
'set-last-online-time': {
method: 'POST',
authed: true,
rateLimited: false,
props: z.object({}),
},
'get-notifications': {
method: 'GET',
authed: true,
rateLimited: false,
returns: [] as Notification[],
props: z
.object({
@@ -418,6 +468,7 @@ export const API = (_apiTypeCheck = {
'create-private-user-message': {
method: 'POST',
authed: true,
rateLimited: true,
returns: {} as any,
props: z.object({
content: contentSchema,
@@ -427,6 +478,7 @@ export const API = (_apiTypeCheck = {
'create-private-user-message-channel': {
method: 'POST',
authed: true,
rateLimited: true,
returns: {} as any,
props: z.object({
userIds: z.array(z.string()),
@@ -435,6 +487,7 @@ export const API = (_apiTypeCheck = {
'update-private-user-message-channel': {
method: 'POST',
authed: true,
rateLimited: true,
returns: {} as any,
props: z.object({
channelId: z.number(),
@@ -444,6 +497,7 @@ export const API = (_apiTypeCheck = {
'leave-private-user-message-channel': {
method: 'POST',
authed: true,
rateLimited: true,
returns: {} as any,
props: z.object({
channelId: z.number(),
@@ -452,15 +506,39 @@ export const API = (_apiTypeCheck = {
'create-compatibility-question': {
method: 'POST',
authed: true,
rateLimited: true,
returns: {} as any,
props: z.object({
question: z.string().min(1).max(MAX_COMPATIBILITY_QUESTION_LENGTH),
options: z.record(z.string(), z.number()),
}),
},
'create-vote': {
method: 'POST',
authed: true,
rateLimited: true,
returns: {} as any,
props: z.object({
title: z.string().min(1),
isAnonymous: z.boolean(),
description: contentSchema,
}),
},
'vote': {
method: 'POST',
authed: true,
rateLimited: true,
returns: {} as any,
props: z.object({
voteId: z.number(),
priority: z.number(),
choice: z.enum(['for', 'abstain', 'against']),
}),
},
'search-location': {
method: 'POST',
authed: false,
authed: true,
rateLimited: true,
returns: {} as any,
props: z.object({
term: z.string(),
@@ -469,13 +547,31 @@ export const API = (_apiTypeCheck = {
},
'search-near-city': {
method: 'POST',
authed: false,
authed: true,
rateLimited: true,
returns: {} as any,
props: z.object({
cityId: z.string(),
radius: z.number().min(1).max(500),
}),
},
'contact': {
method: 'POST',
authed: false,
rateLimited: true,
returns: {} as any,
props: z.object({
content: contentSchema,
userId: z.string().optional(),
}),
},
'get-messages-count': {
method: 'GET',
authed: false,
rateLimited: false,
props: z.object({}),
returns: {} as { count: number },
},
} as const)
export type APIPath = keyof typeof API
@@ -487,8 +583,8 @@ export type ValidatedAPIParams<N extends APIPath> = z.output<
>
export type APIResponse<N extends APIPath> = APISchema<N> extends {
returns: Record<string, any>
}
returns: Record<string, any>
}
? APISchema<N>['returns']
: void

View File

@@ -20,10 +20,10 @@ export class APIError extends Error {
}
}
const prefix = 'v0'
const prefix = ''
export function pathWithPrefix(path: string) {
return `/${prefix}${path}`
return `${prefix}${path}`
}
export function getWebsocketUrl() {
@@ -33,5 +33,5 @@ export function getWebsocketUrl() {
export function getApiUrl(path: string) {
const protocol = IS_LOCAL ? 'http' : 'https'
return `${protocol}://${BACKEND_DOMAIN}/${prefix}/${path}`
return `${protocol}://${BACKEND_DOMAIN}${prefix}/${path}`
}

View File

@@ -54,7 +54,7 @@ export class APIRealtimeClient {
// subscribers by the topic they are subscribed to
subscriptions: Map<string, BroadcastHandler[]>
connectTimeout?: NodeJS.Timeout
heartbeat?: NodeJS.Timeout
heartbeat?: number | undefined;
constructor(url: string) {
this.url = url
@@ -90,10 +90,12 @@ export class APIRealtimeClient {
if (VERBOSE_LOGGING) {
console.info('API websocket opened.')
}
this.heartbeat = setInterval(
async () => this.sendMessage('ping', {}).catch(console.error),
30000
)
// Send a heartbeat ping every 25s
this.heartbeat = window.setInterval(() => {
if (this.ws.readyState === WebSocket.OPEN) {
this.sendMessage('ping', {}).catch(console.error);
}
}, 25000);
if (this.subscriptions.size > 0) {
this.sendMessage('subscribe', {
topics: Array.from(this.subscriptions.keys()),
@@ -105,7 +107,7 @@ export class APIRealtimeClient {
if (VERBOSE_LOGGING) {
console.info(`API websocket closed with code=${ev.code}: ${ev.reason}`)
}
clearInterval(this.heartbeat)
if (this.heartbeat) clearInterval(this.heartbeat)
// mqp: we might need to change how the txn stuff works if we ever want to
// implement "wait until i am subscribed, and then do something" in a component.

View File

@@ -49,19 +49,14 @@ export const baseProfilesSchema = z.object({
pref_gender: genderTypes,
pref_age_min: z.number().min(18).max(100).optional(),
pref_age_max: z.number().min(18).max(100).optional(),
pref_relation_styles: z.array(
z.union([
z.literal('collaboration'),
z.literal('friendship'),
z.literal('relationship'),
])
),
pref_relation_styles: z.array(z.string()),
wants_kids_strength: z.number(),
looking_for_matches: z.boolean(),
photo_urls: z.array(z.string()),
visibility: z.union([z.literal('public'), z.literal('member')]),
bio: contentSchema.optional().nullable(),
bio_length: z.number().optional().nullable(),
geodb_city_id: z.string().optional(),
city: z.string(),
@@ -83,7 +78,6 @@ const optionalProfilesSchema = z.object({
height_in_inches: z.number().optional(),
has_pets: z.boolean().optional(),
education_level: z.string().optional(),
last_online_time: z.string().optional(),
is_smoker: z.boolean().optional(),
drinks_per_month: z.number().min(0).optional(),
is_vegetarian_or_vegan: z.boolean().optional(),
@@ -97,6 +91,7 @@ const optionalProfilesSchema = z.object({
bio: contentSchema.optional().nullable(),
twitter: z.string().optional(),
avatar_url: z.string().optional(),
pref_romantic_styles: z.array(z.string()),
})
export const combinedLoveUsersSchema =

View File

@@ -1,8 +1,8 @@
export const MAX_INT = 99999
export const MIN_INT = -MAX_INT
export const supportEmail = 'compass.meet.info@gmail.com';
export const marketingEmail = 'compass.meet.marketing@gmail.com';
export const supportEmail = 'hello@compassmeet.com';
// export const marketingEmail = 'hello@compassmeet.com';
export const githubRepo = "https://github.com/CompassConnections/Compass";
export const githubIssues = `${githubRepo}/issues`
@@ -10,6 +10,14 @@ export const githubIssues = `${githubRepo}/issues`
export const paypalLink = "https://www.paypal.com/paypalme/CompassConnections"
export const patreonLink = "https://patreon.com/CompassMeet"
export const discordLink = "https://discord.gg/8Vd7jzqjun"
export const stoatLink = "https://stt.gg/YKQp81yA"
export const redditLink = "https://www.reddit.com/r/CompassConnect"
export const xLink = "https://x.com/compassmeet"
export const formLink = "https://forms.gle/tKnXUMAbEreMK6FC6"
export const pStyle = "mt-1 text-gray-800 dark:text-white whitespace-pre-line";
export const IS_MAINTENANCE = false; // set to true to enable maintenance mode banner
export const MIN_BIO_LENGTH = 250;

View File

@@ -4,10 +4,15 @@ export const sendDiscordMessage = async (content: string, channel: string) => {
let webhookUrl = {
members: process.env.DISCORD_WEBHOOK_MEMBERS,
general: process.env.DISCORD_WEBHOOK_GENERAL,
health: process.env.DISCORD_WEBHOOK_HEALTH,
reports: process.env.DISCORD_WEBHOOK_REPORTS,
contact: process.env.DISCORD_WEBHOOK_CONTACT,
}[channel]
if (IS_DEV) webhookUrl = process.env.DISCORD_WEBHOOK_DEV
// console.log(`Discord webhook URL: ${webhookUrl}`, channel, content)
if (!webhookUrl) return
const response = await fetch(webhookUrl!, {

View File

@@ -2,7 +2,7 @@ import {DEV_CONFIG} from './dev'
import {PROD_CONFIG} from './prod'
import {isProd} from "common/envs/is-prod";
export const MAX_DESCRIPTION_LENGTH = 16000
export const MAX_DESCRIPTION_LENGTH = 100000
export const MAX_ANSWER_LENGTH = 240
export const ENV_CONFIG = isProd() ? PROD_CONFIG : DEV_CONFIG
@@ -26,7 +26,7 @@ export const IS_GOOGLE_CLOUD = !!process.env.GOOGLE_CLOUD_PROJECT
export const IS_VERCEL = !!process.env.NEXT_PUBLIC_VERCEL
export const IS_LOCAL = !IS_GOOGLE_CLOUD && !IS_VERCEL
export const HOSTING_ENV = IS_GOOGLE_CLOUD ? 'Google Cloud' : IS_VERCEL ? 'Vercel' : IS_LOCAL ? 'local' : 'unknown'
console.log(`Running in ${HOSTING_ENV} (${ENV})`,);
console.debug(`Running in ${HOSTING_ENV} (${ENV})`,);
// class MissingKeyError implements Error {
// constructor(key: string) {
@@ -68,20 +68,25 @@ export const VERIFIED_USERNAMES = [
export const TEN_YEARS_SECS = 60 * 60 * 24 * 365 * 10
export const RESERVED_PATHS = [
'404',
'_app',
'_document',
'_next',
'about',
'ad',
'add-funds',
'ads',
'admin',
'ads',
'analytics',
'api',
'browse',
'career',
'careers',
'charts',
'chat',
'chats',
'common',
'confirm-email',
'contact',
'contacts',
'create',
@@ -89,6 +94,8 @@ export const RESERVED_PATHS = [
'discord',
'embed',
'facebook',
'faq',
'financials',
'find',
'github',
'google',
@@ -96,18 +103,23 @@ export const RESERVED_PATHS = [
'groups',
'help',
'home',
'index',
'link',
'linkAccount',
'links',
'live',
'login',
'love-questions',
'manifest',
'market',
'markets',
'md',
'members',
'message',
'messages',
'notifications',
'og-test',
'organization',
'payments',
'privacy',
'profile',
@@ -115,16 +127,22 @@ export const RESERVED_PATHS = [
'questions',
'referral',
'referrals',
'register',
'send',
'server-sitemap',
'sign-in',
'sign-in-waiting',
'signin',
'signup',
'sitemap',
'slack',
'social',
'stats',
'styles',
'support',
'team',
'terms',
'tips-bio',
'twitch',
'twitter',
'user',

View File

@@ -5,7 +5,7 @@ export const DEV_CONFIG: EnvConfig = {
domain: 'dev.compassmeet.com',
backendDomain: 'api.dev.compassmeet.com',
supabaseInstanceId: 'zbspxezubpzxmuxciurg',
supabasePwd: 'FO3y0G7chzdq6aE7', // For database write access (dev). A 16-character password with digits and letters.
supabasePwd: 'ZTNlifGKofSKhu8c', // For database write access (dev). A 16-character password with digits and letters.
supabaseAnonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inpic3B4ZXp1YnB6eG11eGNpdXJnIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTc2ODM0MTMsImV4cCI6MjA3MzI1OTQxM30.ZkM7zlawP8Nke0T3KJrqpOQ4DzqPaXTaJXLC2WU8Y7c',
googleApplicationCredentials: 'googleApplicationCredentials-dev.json',
firebaseConfig: {
@@ -13,10 +13,13 @@ export const DEV_CONFIG: EnvConfig = {
authDomain: "compass-57c3c.firebaseapp.com",
projectId: "compass-57c3c",
storageBucket: "compass-57c3c.firebasestorage.app",
privateBucket: 'compass-private.firebasestorage.app',
privateBucket: 'compass-130ba-private',
messagingSenderId: "297460199314",
appId: "1:297460199314:web:c45678c54285910e255b4b",
measurementId: "G-N6LZ64EMJ2",
region: 'us-west1',
},
adminIds: [
'ULxLz04VW1V4vbnj5XLwvzCSkYd2', // Martin
],
}

View File

@@ -44,7 +44,7 @@ export const PROD_CONFIG: EnvConfig = {
authDomain: "compass-130ba.firebaseapp.com",
projectId: "compass-130ba",
storageBucket: "compass-130ba.firebasestorage.app",
privateBucket: 'compass-private.firebasestorage.app',
privateBucket: 'compass-130ba-private',
messagingSenderId: "253367029065",
appId: "1:253367029065:web:b338785af99d4145095e98",
measurementId: "G-2LSQYJQE6P",

View File

@@ -2,11 +2,21 @@ import {Profile, ProfileRow} from "common/love/profile";
import {cloneDeep} from "lodash";
import {filterDefined} from "common/util/array";
// export type TargetArea = {
// lat: number
// lon: number
// radius: number
// }
export type FilterFields = {
orderBy: 'last_online_time' | 'created_time' | 'compatibility_score'
geodbCityIds: string[] | null
lat: number | null
lon: number | null
radius: number | null
genders: string[]
name: string | undefined
shortBio: boolean | undefined
} & Pick<
ProfileRow,
| 'wants_kids_strength'
@@ -17,6 +27,7 @@ export type FilterFields = {
| 'pref_age_min'
| 'pref_age_max'
>
export const orderProfiles = (
profiles: Profile[],
starredUserIds: string[] | undefined
@@ -38,6 +49,9 @@ export const orderProfiles = (
}
export const initialFilters: Partial<FilterFields> = {
geodbCityIds: undefined,
lat: undefined,
lon: undefined,
radius: undefined,
name: undefined,
genders: undefined,
pref_age_max: undefined,
@@ -47,6 +61,11 @@ export const initialFilters: Partial<FilterFields> = {
is_smoker: undefined,
pref_relation_styles: undefined,
pref_gender: undefined,
shortBio: undefined,
orderBy: 'created_time',
}
export type OriginLocation = { id: string; name: string }
export const FilterKeys = Object.keys(initialFilters) as (keyof FilterFields)[]
export type OriginLocation = { id: string; name: string, lat: number, lon: number }

View File

@@ -23,10 +23,10 @@ export const geodbFetch = async (endpoint: string) => {
}
const data = await res.json()
console.log('geodbFetch', endpoint, data)
console.debug('geodbFetch', endpoint, data)
return {status: 'success', data}
} catch (error) {
console.log('geodbFetch', endpoint, error)
console.debug('geodbFetch', endpoint, error)
return {status: 'failure', data: error}
}
}

View File

@@ -5,7 +5,7 @@ const isPreferredGender = (
preferredGenders: string[] | undefined,
gender: string | undefined
) => {
// console.log('isPreferredGender', preferredGenders, gender)
// console.debug('isPreferredGender', preferredGenders, gender)
if (preferredGenders === undefined || preferredGenders.length === 0 || gender === undefined) return true
// If simple gender preference, don't include non-binary.
@@ -19,7 +19,7 @@ const isPreferredGender = (
}
export const areGenderCompatible = (profile1: ProfileRow, profile2: ProfileRow) => {
// console.log('areGenderCompatible', isPreferredGender(profile1.pref_gender, profile2.gender), isPreferredGender(profile2.pref_gender, profile1.gender))
// console.debug('areGenderCompatible', isPreferredGender(profile1.pref_gender, profile2.gender), isPreferredGender(profile2.pref_gender, profile1.gender))
return (
isPreferredGender(profile1.pref_gender, profile2.gender) &&
isPreferredGender(profile2.pref_gender, profile1.gender)

View File

@@ -4,7 +4,7 @@ import { User } from 'common/user'
export type ProfileRow = Row<'profiles'>
export type Profile = ProfileRow & { user: User }
export const getProfileRow = async (userId: string, db: SupabaseClient) => {
console.log('getProfileRow', userId)
console.debug('getProfileRow', userId)
const res = await run(db.from('profiles').select('*').eq('user_id', userId))
return res.data[0]
}

89
common/src/md.ts Normal file
View File

@@ -0,0 +1,89 @@
import type { JSONContent } from '@tiptap/core'
export function jsonToMarkdown(node: JSONContent): string {
if (!node) return ''
// Text node
if (node.type === 'text') {
let text = node.text || ''
if (node.marks) {
for (const mark of node.marks) {
switch (mark.type) {
case 'bold':
text = `**${text}**`
break
case 'italic':
text = `*${text}*`
break
case 'strike':
text = `~~${text}~~`
break
case 'code':
text = `\`${text}\``
break
case 'link':
text = `[${text}](${mark.attrs?.href ?? ''})`
break
}
}
}
return text
}
// Non-text nodes: recursively process children
const content = (node.content || []).map(jsonToMarkdown).join('')
switch (node.type) {
case 'paragraph':
return `${content}\n`
case 'heading': {
const level = node.attrs?.level || 1
return `${'#'.repeat(level)} ${content}\n`
}
case 'bulletList':
return `${content}`
case 'orderedList':
return `${content}`
case 'listItem':
return `- ${content}`
case 'blockquote':
return content
.split('\n')
.map((line) => (line ? `> ${line}` : ''))
.join('\n') + '\n\n'
case 'codeBlock':
return `\`\`\`\n${content}\n\`\`\`\n\n`
case 'horizontalRule':
return `---\n\n`
case 'hardBreak':
return ` \n`
default:
return content
}
}
// function extractTextFromJsonb(bio: JSONContent): string {
// try {
// const texts: string[] = []
// const visit = (node: any) => {
// if (!node) return
// if (Array.isArray(node)) {
// for (const item of node) visit(item)
// return
// }
// if (typeof node === 'object') {
// for (const [k, v] of Object.entries(node)) {
// if (k === 'text' && typeof v === 'string') texts.push(v)
// else visit(v as any)
// }
// }
// }
// visit(bio)
// // Remove extra whitespace and join
// return texts.map((t) => t.trim()).filter(Boolean).join(' ')
// } catch {
// return ''
// }
// }

View File

@@ -25,6 +25,18 @@ export type locationType = {
radius: number
}
const skippedKeys = [
'pref_age_min',
'pref_age_max',
'geodbCityIds',
'orderBy',
'shortBio',
'targetArea',
'lat',
'lon',
'radius',
]
export function formatFilters(filters: Partial<FilterFields>, location: locationType | null): String[] | null {
const entries: String[] = []
@@ -53,7 +65,7 @@ export function formatFilters(filters: Partial<FilterFields>, location: location
const typedKey = key as keyof FilterFields
if (value === undefined || value === null) return
if (typedKey == 'pref_age_min' || typedKey == 'pref_age_max' || typedKey == 'geodbCityIds' || typedKey == 'orderBy') return
if (skippedKeys.includes(typedKey)) return
if (Array.isArray(value) && value.length === 0) return
if (initialFilters[typedKey] === value) return

View File

@@ -20,6 +20,9 @@ export const secrets = (
'NEXT_PUBLIC_FIREBASE_API_KEY',
'DISCORD_WEBHOOK_MEMBERS',
'DISCORD_WEBHOOK_GENERAL',
'DISCORD_WEBHOOK_HEALTH',
'DISCORD_WEBHOOK_REPORTS',
'DISCORD_WEBHOOK_CONTACT',
// Some typescript voodoo to keep the string literal types while being not readonly.
] as const
).concat()
@@ -32,7 +35,7 @@ type SecretId = (typeof secrets)[number]
export const getSecrets = async (credentials?: any, ...ids: SecretId[]) => {
if (!ids.length && IS_LOCAL) return {}
// console.log('Fetching secrets...')
// console.debug('Fetching secrets...')
let client: SecretManagerServiceClient
if (credentials) {
const projectId = credentials['project_id']
@@ -47,7 +50,7 @@ export const getSecrets = async (credentials?: any, ...ids: SecretId[]) => {
const secretIds = ids.length > 0 ? ids : secrets
console.log('secretIds', secretIds)
console.debug('secretIds', secretIds)
const fullSecretNames = secretIds.map(
(secret: string) =>
@@ -75,7 +78,7 @@ export const loadSecretsToEnv = async (credentials?: any) => {
for (const [key, value] of Object.entries(allSecrets)) {
if (key && value) {
process.env[key] = value
// console.log(key, value)
// console.debug(key, value)
}
}
refreshConfig()

View File

@@ -90,7 +90,7 @@ export const getSocialUrl = (site: Site, handle: string) =>
const urler: { [key in Site]: (handle: string) => string } = {
site: (s) => (s.startsWith('http') ? s : `https://${s}`),
okcupid: (s) => (s.startsWith('http') ? s : `https://${s}`),
x: (s) => `https://x.com/${s}`,
x: (s) => s.startsWith('http') ? s : `https://x.com/${s}`,
discord: (s) =>
(s.length === 17 || s.length === 18) && !isNaN(parseInt(s, 10))
? `https://discord.com/users/${s}` // discord user id
@@ -98,14 +98,14 @@ const urler: { [key in Site]: (handle: string) => string } = {
bluesky: (s) => `https://bsky.app/profile/${s}`,
mastodon: (s) =>
s.includes('@') ? `https://${s.split('@')[1]}/@${s.split('@')[0]}` : s,
substack: (s) => `https://${s}.substack.com`,
instagram: (s) => `https://instagram.com/${s}`,
github: (s) => `https://github.com/${s}`,
linkedin: (s) => `https://linkedin.com/in/${s}`,
facebook: (s) => `https://facebook.com/${s}`,
spotify: (s) => `https://open.spotify.com/user/${s}`,
paypal: (s) => `https://paypal.com/paypalme/${s}`,
patreon: (s) => `https://patreon.com/${s}`,
substack: (s) => s.startsWith('http') ? s : `https://${s}.substack.com`,
instagram: (s) => s.startsWith('http') ? s : `https://instagram.com/${s}`,
github: (s) => s.startsWith('http') ? s : `https://github.com/${s}`,
linkedin: (s) => s.startsWith('http') ? s : `https://linkedin.com/in/${s}`,
facebook: (s) => s.startsWith('http') ? s : `https://facebook.com/${s}`,
spotify: (s) => s.startsWith('http') ? s : `https://open.spotify.com/user/${s}`,
paypal: (s) => s.startsWith('http') ? s : `https://paypal.com/paypalme/${s}`,
patreon: (s) => s.startsWith('http') ? s : `https://patreon.com/${s}`,
calendly: (s) => (s.startsWith('http') ? s : `https://${s}`),
datingdoc: (s) => (s.startsWith('http') ? s : `https://${s}`),
friendshipdoc: (s) => (s.startsWith('http') ? s : `https://${s}`),

View File

@@ -44,6 +44,35 @@ export type Database = {
}
Relationships: []
}
contact: {
Row: {
content: Json | null
created_time: string | null
id: string
user_id: string | null
}
Insert: {
content?: Json | null
created_time?: string | null
id?: string
user_id?: string | null
}
Update: {
content?: Json | null
created_time?: string | null
id?: string
user_id?: string | null
}
Relationships: [
{
foreignKeyName: 'contact_user_id_fkey'
columns: ['user_id']
isOneToOne: false
referencedRelation: 'users'
referencedColumns: ['id']
}
]
}
love_answers: {
Row: {
created_time: string
@@ -221,45 +250,6 @@ export type Database = {
}
Relationships: []
}
profile_comments: {
Row: {
content: Json
created_time: string
hidden: boolean
id: number
on_user_id: string
reply_to_comment_id: number | null
user_avatar_url: string
user_id: string
user_name: string
user_username: string
}
Insert: {
content: Json
created_time?: string
hidden?: boolean
id?: never
on_user_id: string
reply_to_comment_id?: number | null
user_avatar_url: string
user_id: string
user_name: string
user_username: string
}
Update: {
content?: Json
created_time?: string
hidden?: boolean
id?: never
on_user_id?: string
reply_to_comment_id?: number | null
user_avatar_url?: string
user_id?: string
user_name?: string
user_username?: string
}
Relationships: []
}
private_user_message_channel_members: {
Row: {
channel_id: number
@@ -401,10 +391,52 @@ export type Database = {
}
Relationships: []
}
profile_comments: {
Row: {
content: Json
created_time: string
hidden: boolean
id: number
on_user_id: string
reply_to_comment_id: number | null
user_avatar_url: string
user_id: string
user_name: string
user_username: string
}
Insert: {
content: Json
created_time?: string
hidden?: boolean
id?: number
on_user_id: string
reply_to_comment_id?: number | null
user_avatar_url: string
user_id: string
user_name: string
user_username: string
}
Update: {
content?: Json
created_time?: string
hidden?: boolean
id?: number
on_user_id?: string
reply_to_comment_id?: number | null
user_avatar_url?: string
user_id?: string
user_name?: string
user_username?: string
}
Relationships: []
}
profiles: {
Row: {
age: number | null
bio: Json | null
bio_length: number | null
bio_text: string | null
bio_tsv: unknown | null
born_in_location: string | null
city: string
city_latitude: number | null
@@ -424,7 +456,6 @@ export type Database = {
is_smoker: boolean | null
is_vegetarian_or_vegan: boolean | null
last_modification_time: string
last_online_time: string
looking_for_matches: boolean
messaging_status: string
occupation: string | null
@@ -436,6 +467,7 @@ export type Database = {
pref_age_min: number | null
pref_gender: string[]
pref_relation_styles: string[]
pref_romantic_styles: string[] | null
referred_by_username: string | null
region_code: string | null
religious_belief_strength: number | null
@@ -443,13 +475,16 @@ export type Database = {
twitter: string | null
university: string | null
user_id: string
visibility: Database['public']['Enums']['profile_visibility']
visibility: Database['public']['Enums']['lover_visibility']
wants_kids_strength: number
website: string | null
}
Insert: {
age?: number | null
bio?: Json | null
bio_length?: number | null
bio_text?: string | null
bio_tsv?: unknown | null
born_in_location?: string | null
city: string
city_latitude?: number | null
@@ -465,11 +500,10 @@ export type Database = {
geodb_city_id?: string | null
has_kids?: number | null
height_in_inches?: number | null
id?: never
id?: number
is_smoker?: boolean | null
is_vegetarian_or_vegan?: boolean | null
last_modification_time?: string
last_online_time?: string
looking_for_matches?: boolean
messaging_status?: string
occupation?: string | null
@@ -481,6 +515,7 @@ export type Database = {
pref_age_min?: number | null
pref_gender: string[]
pref_relation_styles: string[]
pref_romantic_styles?: string[] | null
referred_by_username?: string | null
region_code?: string | null
religious_belief_strength?: number | null
@@ -488,13 +523,16 @@ export type Database = {
twitter?: string | null
university?: string | null
user_id: string
visibility?: Database['public']['Enums']['profile_visibility']
visibility?: Database['public']['Enums']['lover_visibility']
wants_kids_strength?: number
website?: string | null
}
Update: {
age?: number | null
bio?: Json | null
bio_length?: number | null
bio_text?: string | null
bio_tsv?: unknown | null
born_in_location?: string | null
city?: string
city_latitude?: number | null
@@ -510,11 +548,10 @@ export type Database = {
geodb_city_id?: string | null
has_kids?: number | null
height_in_inches?: number | null
id?: never
id?: number
is_smoker?: boolean | null
is_vegetarian_or_vegan?: boolean | null
last_modification_time?: string
last_online_time?: string
looking_for_matches?: boolean
messaging_status?: string
occupation?: string | null
@@ -526,6 +563,7 @@ export type Database = {
pref_age_min?: number | null
pref_gender?: string[]
pref_relation_styles?: string[]
pref_romantic_styles?: string[] | null
referred_by_username?: string | null
region_code?: string | null
religious_belief_strength?: number | null
@@ -533,7 +571,7 @@ export type Database = {
twitter?: string | null
university?: string | null
user_id?: string
visibility?: Database['public']['Enums']['profile_visibility']
visibility?: Database['public']['Enums']['lover_visibility']
wants_kids_strength?: number
website?: string | null
}
@@ -590,6 +628,29 @@ export type Database = {
}
]
}
user_activity: {
Row: {
last_online_time: string
user_id: string
}
Insert: {
last_online_time: string
user_id: string
}
Update: {
last_online_time?: string
user_id?: string
}
Relationships: [
{
foreignKeyName: 'user_activity_user_id_fkey'
columns: ['user_id']
isOneToOne: true
referencedRelation: 'users'
referencedColumns: ['id']
}
]
}
user_events: {
Row: {
ad_id: string | null
@@ -668,6 +729,83 @@ export type Database = {
}
Relationships: []
}
vote_results: {
Row: {
choice: number
created_time: string
id: number
priority: number
user_id: string
vote_id: number
}
Insert: {
choice: number
created_time?: string
id?: never
priority: number
user_id: string
vote_id: number
}
Update: {
choice?: number
created_time?: string
id?: never
priority?: number
user_id?: string
vote_id?: number
}
Relationships: [
{
foreignKeyName: 'vote_results_user_id_fkey'
columns: ['user_id']
isOneToOne: false
referencedRelation: 'users'
referencedColumns: ['id']
},
{
foreignKeyName: 'vote_results_vote_id_fkey'
columns: ['vote_id']
isOneToOne: false
referencedRelation: 'votes'
referencedColumns: ['id']
}
]
}
votes: {
Row: {
created_time: string
creator_id: string
description: Json | null
id: number
is_anonymous: boolean | null
title: string
}
Insert: {
created_time?: string
creator_id: string
description?: Json | null
id?: never
is_anonymous?: boolean | null
title: string
}
Update: {
created_time?: string
creator_id?: string
description?: Json | null
id?: never
is_anonymous?: boolean | null
title?: string
}
Relationships: [
{
foreignKeyName: 'votes_creator_id_fkey'
columns: ['creator_id']
isOneToOne: false
referencedRelation: 'users'
referencedColumns: ['id']
}
]
}
}
Views: {
[_ in never]: never
@@ -693,10 +831,29 @@ export type Database = {
Args: Record<PropertyKey, never>
Returns: Record<string, unknown>[]
}
get_love_question_answers_and_lovers: {
Args: { p_question_id: number }
Returns: Record<string, unknown>[]
}
get_love_question_answers_and_profiles: {
Args: { p_question_id: number }
Returns: Record<string, unknown>[]
}
get_votes_with_results: {
Args: Record<PropertyKey, never>
Returns: {
created_time: string
creator_id: string
description: Json
id: number
is_anonymous: boolean
priority: number
title: string
votes_abstain: number
votes_against: number
votes_for: number
}[]
}
gtrgm_compress: {
Args: { '': unknown }
Returns: unknown
@@ -750,12 +907,12 @@ export type Database = {
Returns: Json
}
ts_to_millis: {
Args: { ts: string }
Args: { ts: string } | { ts: string }
Returns: number
}
}
Enums: {
profile_visibility: 'public' | 'member'
lover_visibility: 'public' | 'member'
}
CompositeTypes: {
[_ in never]: never
@@ -883,7 +1040,7 @@ export type CompositeTypes<
export const Constants = {
public: {
Enums: {
profile_visibility: ['public', 'member'],
lover_visibility: ['public', 'member'],
},
},
} as const

View File

@@ -30,7 +30,7 @@ export function createClient(
opts?: SupabaseClientOptionsGeneric<'public'>
) {
const url = `https://${instanceId}.supabase.co`
// console.log('createClient', instanceId, key, opts)
// console.debug('createClient', instanceId, key, opts)
return createClientGeneric(
url,
key,

View File

@@ -57,6 +57,7 @@ export const getDefaultNotificationPreferences = (isDev?: boolean) => {
return defaults
}
export const UNSUBSCRIBE_URL = 'https://compassmeet.com/notifications';
export const getNotificationDestinationsForUser = (
privateUser: PrivateUser,
type: notification_preference
@@ -72,7 +73,7 @@ export const getNotificationDestinationsForUser = (
destinations.includes('browser') && !opt_out.includes('browser'),
sendToMobile:
destinations.includes('mobile') && !opt_out.includes('mobile'),
unsubscribeUrl: 'https://compassmeet.com/notifications',
unsubscribeUrl: UNSUBSCRIBE_URL,
urlToManageThisNotification: '/notifications',
}
}

View File

@@ -46,6 +46,11 @@ export type PrivateUser = {
blockedByUserIds: string[]
}
export type UserActivity = {
user_id: string // same as User.id
last_online_time: string
}
export type UserAndPrivateUser = { user: User; privateUser: PrivateUser }
export function getCurrentUtcTime(): Date {

View File

@@ -67,7 +67,7 @@ export async function baseApiCall(props: {
body:
params == null || method === 'GET' ? undefined : JSON.stringify(params),
})
// console.log(req)
// console.log('Request', req)
return fetch(req).then(async (resp) => {
const json = (await resp.json()) as { [k: string]: any }
if (!resp.ok) {

View File

@@ -32,7 +32,7 @@ export function factorizeMatrix(
const mFeatures = fillMatrix(m, FEATURES, initCell)
const nFeatures = fillMatrix(n, FEATURES, initCell)
console.log('rows', m, 'columns', n, 'numPoints', points)
console.debug('rows', m, 'columns', n, 'numPoints', points)
const updateFeature = (a: number, b: number, error: number) =>
a + LEARNING_RATE * (2 * error * b - REGULARIZATION_RATE * a)
@@ -75,7 +75,7 @@ export function factorizeMatrix(
}
}
}
console.log(iter, 'error', totalError / points)
console.debug(iter, 'error', totalError / points)
// Complete factorization process if total error falls below a certain threshold
if (totalError / points < THRESHOLD) break

View File

@@ -0,0 +1,7 @@
export const ORDER_BY = ['recent', 'mostVoted', 'priority'] as const
export type OrderBy = typeof ORDER_BY[number]
export const Constants: Record<OrderBy, string> = {
recent: 'Most recent',
mostVoted: 'Most voted',
priority: 'Highest Priority',
}

View File

@@ -44,7 +44,7 @@ export function wantsKidsToHasKidsFilter(wantsKidsStrength: wantsKidsDatabase) {
export function wantsKidsDatabaseToWantsKidsFilter(
wantsKidsStrength: wantsKidsDatabase
) {
// console.log(wantsKidsStrength)
// console.debug(wantsKidsStrength)
if (wantsKidsStrength == wantsKidsLabels.no_preference.strength) {
return wantsKidsLabels.no_preference.strength
}

View File

@@ -5,7 +5,7 @@
"rules": "storage.rules"
},
{
"bucket": "compass-130ba-private.firebasestorage.app",
"bucket": "compass-130ba-private",
"rules": "private-storage.rules"
}
]

View File

@@ -1,6 +1,6 @@
{
"name": "compass",
"version": "1.2.0",
"version": "1.4.0",
"private": true,
"workspaces": [
"common",

View File

@@ -5,7 +5,7 @@ cd "$(dirname "$0")"/..
source .env
export url=http://localhost:8088/v0
export url=http://localhost:8088
#export url=https://api.compassmeet.com
export endpoint=/internal/send-search-notifications

View File

@@ -9,6 +9,9 @@ if [ ! -f .env ]; then
echo ".env file created from .env.example"
fi
source .env.example
source .env
echo $GOOGLE_CREDENTIALS_ENC_PWD
openssl enc -d -aes-256-cbc -pbkdf2 -iter 100000 -in secrets/googleApplicationCredentials-dev.json.enc -out backend/shared/src/googleApplicationCredentials-dev.json -pass pass:$GOOGLE_CREDENTIALS_ENC_PWD

View File

@@ -58,7 +58,7 @@ function AddCompatibilityQuestionModal(props: {
)
const afterAddQuestion = (newQuestion: rowFor<'love_questions'>) => {
setDbQuestion(newQuestion)
console.log('setDbQuestion', newQuestion)
console.debug('setDbQuestion', newQuestion)
}
return (
@@ -137,7 +137,7 @@ function CreateCompatibilityModalContent(props: {
options: generateJson(),
};
const newQuestion = await api('create-compatibility-question', data)
console.log('create-compatibility-question', newQuestion, data)
console.debug('create-compatibility-question', newQuestion, data)
const q = newQuestion?.question
if (q) {
afterAddQuestion(q as rowFor<'love_questions'>)

View File

@@ -5,6 +5,7 @@ import { Button } from 'web/components/buttons/button'
import { Col } from 'web/components/layout/col'
import { MODAL_CLASS, Modal } from 'web/components/layout/modal'
import { AnswerCompatibilityQuestionContent } from './answer-compatibility-question-content'
import router from "next/router";
export function AnswerCompatibilityQuestionButton(props: {
user: User | null | undefined
@@ -22,13 +23,16 @@ export function AnswerCompatibilityQuestionButton(props: {
} = props
const [open, setOpen] = useState(fromSignup ?? false)
if (!user) return null
if (otherQuestions.length === 0) return null
const isCore = otherQuestions.some((q) => q.importance_score === 0)
const questionsToAnswer = isCore ? otherQuestions.filter((q) => q.importance_score === 0) : otherQuestions
return (
<>
{size === 'md' ? (
<Button onClick={() => setOpen(true)} color="gray-outline">
Answer Questions{' '}
<span className="text-primary-600 ml-2">
+{otherQuestions.length}
+{questionsToAnswer.length}
</span>
</Button>
) : (
@@ -43,8 +47,11 @@ export function AnswerCompatibilityQuestionButton(props: {
open={open}
setOpen={setOpen}
user={user}
otherQuestions={otherQuestions}
otherQuestions={questionsToAnswer}
refreshCompatibilityAll={refreshCompatibilityAll}
onClose={() => {
if (fromSignup) router.push('/')
}}
/>
</>
)
@@ -83,8 +90,9 @@ function AnswerCompatibilityQuestionModal(props: {
user: User
otherQuestions: QuestionWithCountType[]
refreshCompatibilityAll: () => void
onClose?: () => void
}) {
const { open, setOpen, user, otherQuestions, refreshCompatibilityAll } = props
const { open, setOpen, user, otherQuestions, refreshCompatibilityAll, onClose } = props
const [questionIndex, setQuestionIndex] = useState(0)
return (
<Modal
@@ -93,6 +101,7 @@ function AnswerCompatibilityQuestionModal(props: {
onClose={() => {
refreshCompatibilityAll()
setQuestionIndex(0)
onClose?.()
}}
>
<Col className={MODAL_CLASS}>

View File

@@ -36,10 +36,10 @@ type ImportanceColorsType = {
}
export const IMPORTANCE_RADIO_COLORS: ImportanceColorsType = {
0: `bg-stone-400 ring-stone-400 dark:bg-stone-500 dark:ring-stone-500`,
1: `bg-teal-200 ring-teal-200 dark:bg-teal-100 dark:ring-teal-100 `,
2: `bg-teal-300 ring-teal-300 dark:bg-teal-200 dark:ring-teal-200 `,
3: `bg-teal-400 ring-teal-400`,
0: `bg-teal-300 ring-teal-200`,
1: `bg-teal-500 ring-teal-200`,
2: `bg-teal-700 ring-teal-300`,
3: `bg-teal-900 ring-teal-400`,
}
export const IMPORTANCE_DISPLAY_COLORS: ImportanceColorsType = {
@@ -157,6 +157,11 @@ export function AnswerCompatibilityQuestionContent(props: {
return (
<Col className="h-full w-full gap-4">
<Col className="gap-1">
{compatibilityQuestion.importance_score > 0 && <Row className="text-blue-400 -mt-4 w-full justify-start text-sm">
<span>
Massive upgrade coming soon! More prompts, better predictive power, filtered by category, etc.
</span>
</Row>}
{index !== null &&
index !== undefined &&
total !== null &&
@@ -184,7 +189,7 @@ export function AnswerCompatibilityQuestionContent(props: {
<Col
className={clsx(
SCROLLABLE_MODAL_CLASS,
'h-[30rem] w-full gap-4 sm:h-[30rem]'
'h-[20rem] w-full gap-4 sm:h-[30rem]'
)}
>
<Col className="gap-2">

View File

@@ -402,7 +402,7 @@ function CompatibilityAnswerBlock(props: {
)}
</Row>
</Row>
<Row className="bg-canvas-50 w-fit gap-1 rounded px-2 py-1 text-sm">
<Row className="bg-canvas-100 w-fit gap-1 rounded px-2 py-1 text-sm">
{answerText}
</Row>
<Row className="px-2 -mt-4">
@@ -417,11 +417,11 @@ function CompatibilityAnswerBlock(props: {
? 'Acceptable'
: 'Also acceptable'}
</div>
<Row className="flex-wrap gap-2 -mt-2">
<Row className="flex-wrap gap-2 mt-0">
{distinctPreferredAnswersText.map((text) => (
<Row
key={text}
className="bg-canvas-50 w-fit gap-1 rounded px-2 py-1 text-sm"
className="bg-canvas-100 w-fit gap-1 rounded px-2 py-1 text-sm"
>
{text}
</Row>

View File

@@ -1,29 +1,29 @@
import { User } from 'common/user'
import { useOtherAnswers } from 'web/hooks/use-other-answers'
import { QuestionWithCountType } from 'web/hooks/use-questions'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { Avatar } from 'web/components/widgets/avatar'
import { Linkify } from 'web/components/widgets/linkify'
import { LoadingIndicator } from 'web/components/widgets/loading-indicator'
import { UserLink } from 'web/components/widgets/user-link'
import { Gender, convertGender } from 'common/gender'
import { capitalize } from 'lodash'
import {User} from 'common/user'
import {useOtherAnswers} from 'web/hooks/use-other-answers'
import {QuestionWithCountType} from 'web/hooks/use-questions'
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {Avatar} from 'web/components/widgets/avatar'
import {Linkify} from 'web/components/widgets/linkify'
import {CompassLoadingIndicator} from 'web/components/widgets/loading-indicator'
import {UserLink} from 'web/components/widgets/user-link'
import {convertGender, Gender} from 'common/gender'
import {capitalize} from 'lodash'
import clsx from 'clsx'
import { shortenedFromNow } from 'web/lib/util/shortenedFromNow'
import {shortenedFromNow} from 'web/lib/util/shortenedFromNow'
export function OtherProfileAnswers(props: {
question: QuestionWithCountType
user?: User
className?: string
}) {
const { question, className } = props
const {question, className} = props
const otherAnswers = useOtherAnswers(question.id)
const shownAnswers = otherAnswers?.filter(
(a) => a.multiple_choice != null || a.free_response || a.integer
)
if (otherAnswers === undefined) return <LoadingIndicator />
if (otherAnswers === undefined) return <CompassLoadingIndicator/>
if (
(otherAnswers === null ||
otherAnswers.length ||
@@ -50,7 +50,7 @@ export function OtherProfileAnswers(props: {
/>
<Col>
<span className="text-sm">
<UserLink user={answerUser} />, {otherAnswer.age}
<UserLink user={answerUser}/>, {otherAnswer.age}
</span>
<Row className="gap-1 text-xs">
{otherAnswer.city} {' '}

View File

@@ -1,16 +1,18 @@
import { JSONContent } from '@tiptap/core'
import { MAX_DESCRIPTION_LENGTH } from 'common/envs/constants'
import { Profile } from 'common/love/profile'
import { tryCatch } from 'common/util/try-catch'
import { Button } from 'web/components/buttons/button'
import { Col } from 'web/components/layout/col'
import { Row } from 'web/components/layout/row'
import { TextEditor, useTextEditor } from 'web/components/widgets/editor'
import { updateProfile } from 'web/lib/api'
import { track } from 'web/lib/service/analytics'
import React, {useState} from "react";
import {Editor} from '@tiptap/core'
import {MAX_DESCRIPTION_LENGTH} from 'common/envs/constants'
import {Profile} from 'common/love/profile'
import {tryCatch} from 'common/util/try-catch'
import {Button} from 'web/components/buttons/button'
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {TextEditor, useTextEditor} from 'web/components/widgets/editor'
import {updateProfile} from 'web/lib/api'
import {track} from 'web/lib/service/analytics'
import {useEffect, useState} from "react";
import ReactMarkdown from "react-markdown";
import Link from "next/link"
import {MIN_BIO_LENGTH} from "common/constants";
import {ShowMore} from 'web/components/widgets/show-more'
const placeHolder = "Tell us about yourself — and what you're looking for!";
@@ -20,40 +22,16 @@ Write a clear and engaging bio to help others understand who you are and the con
- Connection goals (friendship, romantic, collaborative) and availability
- What makes you unique and what you care about
- Expectations, boundaries, and personality traits
- How to contact you or start a conversation (email, social media, etc.)
- Optional: romantic preferences, lifestyle habits, and conversation starters
`
export function CharLimitText() {
return <p>Profiles with fewer than 250 characters will be hidden by default from the profile grid write a meaningful bio so others can find you through keyword search and connect intentionally.</p>
}
export function BioTips() {
const [showMoreInfo, setShowMoreInfo] = useState(false)
return (
<div className="mt-2 mb-4">
<button
type="button"
onClick={() => setShowMoreInfo(!showMoreInfo)}
className="text-sm text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300 flex items-center"
>
{showMoreInfo ? 'Hide info' : 'Tips'}
<svg
className={`w-4 h-4 ml-1 transition-transform ${showMoreInfo ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7"/>
</svg>
</button>
{showMoreInfo && (
<div className="mt-2 p-3 rounded-md text-sm customlink">
<ReactMarkdown>{tips}</ReactMarkdown>
<Link href="/tips-bio" target="_blank">Read full tips for writing a high-quality bio</Link>
</div>
)}
</div>
<ShowMore labelClosed="Tips" labelOpen="Hide info" className={'customlink text-sm'}>
<ReactMarkdown>{tips}</ReactMarkdown>
<Link href="/tips-bio" target="_blank">Read full tips for writing a high-quality bio</Link>
</ShowMore>
)
}
@@ -62,18 +40,16 @@ export function EditableBio(props: {
onSave: () => void
onCancel?: () => void
}) {
const { profile, onCancel, onSave } = props
const editor = useTextEditor({
max: MAX_DESCRIPTION_LENGTH,
defaultValue: (profile.bio as JSONContent) ?? '',
placeholder: placeHolder,
})
const {profile, onCancel, onSave} = props
const [editor, setEditor] = useState<any>(null)
const [textLength, setTextLength] = useState(0);
const hideButtons = editor?.getText().length === 0 && !profile.bio
const hideButtons = (textLength === 0) && !profile.bio
const saveBio = async () => {
if (!editor) return
const { error } = await tryCatch(updateProfile({ bio: editor.getJSON() }))
console.log(editor.getText().length)
const {error} = await tryCatch(updateProfile({bio: editor.getJSON(), bio_length: editor.getText().length}))
if (error) {
console.error(error)
@@ -85,10 +61,15 @@ export function EditableBio(props: {
return (
<Col className="relative w-full">
<CharLimitText/>
<BioTips/>
<TextEditor editor={editor} />
<BaseBio
defaultValue={profile.bio}
onEditor={(e) => {
setEditor(e);
e?.on('update', () => {
setTextLength(e.getText().length);
});
}}
/>
{!hideButtons && (
<Row className="absolute bottom-1 right-1 justify-between gap-2">
{onCancel && (
@@ -112,40 +93,53 @@ export function EditableBio(props: {
}
export function SignupBio(props: {
onChange: (e: JSONContent) => void
onChange: (e: Editor) => void
}) {
const { onChange } = props
const editor = useTextEditor({
max: MAX_DESCRIPTION_LENGTH,
defaultValue: '',
placeholder: placeHolder,
})
// const [charLength, setCharLength] = useState(0)
const {onChange} = props
return (
<Col className="relative w-full">
<CharLimitText/>
<BioTips/>
<TextEditor
editor={editor}
onBlur={() => {
// console.log('onchange', editor?.getText())
<BaseBio
onBlur={(editor) => {
if (!editor) return
const e = editor.getJSON()
// console.log(e)
// const text = e.content.map((block: any) => block.content?.map((c: any) => c.text).join('') ?? '').join('');
// setCharLength(text.length)
// console.log(text, text.length)
// if (text.length < 250) {
// return; // do not save
// }
// console.log('bio changed', e, profile.bio);
onChange(e)
onChange(editor)
}}
/>
{/*<p>{charLength} / 250</p>*/}
</Col>
)
}
interface BaseBioProps {
defaultValue?: any
onBlur?: (editor: any) => void
onEditor?: (editor: any) => void
}
export function BaseBio({defaultValue, onBlur, onEditor}: BaseBioProps) {
const editor = useTextEditor({
// extensions: [StarterKit],
max: MAX_DESCRIPTION_LENGTH,
defaultValue: defaultValue,
placeholder: placeHolder,
})
const textLength = editor?.getText().length ?? 0
useEffect(() => {
onEditor?.(editor)
}, [editor, onEditor])
return (
<div>
{textLength < MIN_BIO_LENGTH &&
<p>
Add {MIN_BIO_LENGTH - textLength} more {MIN_BIO_LENGTH - textLength === 1 ? 'character' : 'characters'} so
your profile can appear in search resultsor leave it for now and explore others profiles first.
</p>
}
<BioTips/>
<TextEditor
editor={editor}
onBlur={() => onBlur?.(editor)}
/>
</div>
)
}

View File

@@ -55,7 +55,7 @@
// // })
// setDialogOpen(false)
//
// // console.log('result', result)
// // console.debug('result', result)
//
// // if (result.success) {
// // window.location.reload()

View File

@@ -0,0 +1,18 @@
import Link from "next/link";
export const GeneralButton = (props: {
url: string
content: string
}) => {
const {url, content} = props
return <div className="rounded-xl shadow p-6 flex flex-col items-center">
<Link
href={url}
className="px-6 py-2 rounded-full bg-gray-200 text-gray-800 font-semibold text-lg shadow hover:bg-gray-300 dark:bg-gray-700 dark:text-white dark:hover:bg-gray-600 transition"
target={url.startsWith('http') ? '_blank' : undefined}
rel={url.startsWith('http') ? 'noopener noreferrer' : undefined}
>
{content}
</Link>
</div>;
}

View File

@@ -16,6 +16,7 @@ import { SimpleCopyTextButton } from 'web/components/buttons/copy-link-button'
import { api } from 'web/lib/api'
import { buildArray } from 'common/util/array'
import { DeleteYourselfButton } from '../profile/delete-yourself'
import {toast} from "react-hot-toast";
export function MoreOptionsUserButton(props: { user: User }) {
const { user } = props
@@ -55,11 +56,22 @@ export function MoreOptionsUserButton(props: { user: User }) {
<Button
color={'red'}
size="xs"
onClick={() => {
api('ban-user', {
userId,
unban: user.isBannedFromPosting ?? false,
})
onClick={async () => {
await toast.promise(
api('ban-user', {
userId,
unban: user.isBannedFromPosting ?? false,
}),
{
loading: 'Banning...',
success: () => {
return 'User banned!'
},
error: () => {
return 'Error banning user'
},
}
)
}}
>
{user.isBannedFromPosting ? 'Banned' : 'Ban User'}

View File

@@ -59,7 +59,7 @@ export const ChatMessageItem = memo(function ChatMessageItem(props: {
userId={id}
/>
)}
<Col className="@sm:max-w-[calc(100vw-6rem)] @md:max-w-[80%] max-w-[calc(100vw-2rem)]">
<Col className="sm:max-w-[calc(100vw-6rem)] md:max-w-[70%]">
{firstOfUser && !isMe && chat.visibility !== 'system_status' && (
<Row className={'items-center gap-3'}>
<Link

View File

@@ -0,0 +1,57 @@
import {Col} from 'web/components/layout/col'
import {Row} from 'web/components/layout/row'
import {useUser} from 'web/hooks/use-user'
import {TextEditor, useTextEditor} from "web/components/widgets/editor";
import {JSONContent} from "@tiptap/core";
import {MAX_DESCRIPTION_LENGTH} from "common/envs/constants";
import {Button} from "web/components/buttons/button";
import {api} from "web/lib/api";
import {Title} from "web/components/widgets/title";
import toast from "react-hot-toast";
export function ContactComponent() {
const user = useUser()
const editor = useTextEditor({
max: MAX_DESCRIPTION_LENGTH,
defaultValue: '',
placeholder: 'Contact us here...',
})
const hideButton = editor?.getText().length == 0
return (
<Col className="mx-2">
<Title className="!mb-2 text-3xl">Contact</Title>
<Col>
<div className={'mb-2'}>
<TextEditor
editor={editor}
/>
</div>
{!hideButton && (
<Row className="right-1 justify-between gap-2">
<Button
size="xs"
onClick={async () => {
if (!editor) return
const data = {
content: editor.getJSON() as JSONContent,
userId: user?.id,
};
const result = await api('contact', data).catch(() => {
toast.error('Failed to contact — try again or contact us...')
})
if (!result) return
editor.commands.clearContent()
toast.success('Thank you for your message!')
}}
>
Submit
</Button>
</Row>
)}
</Col>
</Col>
)
}

View File

@@ -1,13 +1,40 @@
export const RELATIONSHIP_CHOICES = {
// Monogamous: 'mono',
// Polyamorous: 'poly',
// 'Open Relationship': 'open',
// Other: 'other',
Collaboration: 'collaboration',
Friendship: 'friendship',
Relationship: 'relationship',
};
export const ROMANTIC_CHOICES = {
Monogamous: 'mono',
Polyamorous: 'poly',
'Open Relationship': 'open',
};
export const POLITICAL_CHOICES = {
Progressive: 'progressive',
Liberal: 'liberal',
'Moderate / Centrist': 'moderate',
Conservative: 'conservative',
Socialist: 'socialist',
Nationalist: 'nationalist',
Populist: 'populist',
'Green / Eco-Socialist': 'green',
Technocratic: 'technocratic',
Libertarian: 'libertarian',
'Effective Accelerationism': 'e/acc',
'Pause AI / Tech Skeptic': 'pause ai',
'Independent / Other': 'other',
}
export const REVERTED_RELATIONSHIP_CHOICES = Object.fromEntries(
Object.entries(RELATIONSHIP_CHOICES).map(([key, value]) => [value, key])
);
export const REVERTED_ROMANTIC_CHOICES = Object.fromEntries(
Object.entries(ROMANTIC_CHOICES).map(([key, value]) => [value, key])
);
export const REVERTED_POLITICAL_CHOICES = Object.fromEntries(
Object.entries(POLITICAL_CHOICES).map(([key, value]) => [value, key])
);

View File

@@ -13,6 +13,7 @@ import {RelationshipFilter, RelationshipFilterText,} from './relationship-filter
import {MyMatchesToggle} from './my-matches-toggle'
import {Profile} from 'common/love/profile'
import {FilterFields} from "common/filters";
import {ShortBioToggle} from "web/components/filters/short-bio-toggle";
export function DesktopFilters(props: {
filters: Partial<FilterFields>
@@ -133,6 +134,12 @@ export function DesktopFilters(props: {
}
popoverClassName="bg-canvas-50"
/>
{/* Short Bios */}
<ShortBioToggle
updateFilter={updateFilter}
filters={filters}
hidden={false}
/>
{/* PREFERRED GENDER */}
{/*<CustomizeableDropdown*/}
{/* buttonContent={(open: boolean) => (*/}

Some files were not shown because too many files have changed in this diff Show More