140 Commits
1.1.2 ... 1.3.0

Author SHA1 Message Date
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
MartinBraquet
b30af128c7 Release 2025-10-07 22:11:34 +02:00
MartinBraquet
72c31ae097 Add compat score math video link 2025-10-07 21:25:08 +02:00
Martin Braquet
d2c608021d Improve home (#10) 2025-10-05 10:38:04 +02:00
Martin Braquet
1f36fb2413 Update faq.md 2025-10-05 10:14:09 +02:00
Martin Braquet
16a0cbcecf Update about.tsx 2025-10-05 10:00:30 +02:00
Martin Braquet
e068e246aa Update faq.md 2025-10-03 13:58:31 +02:00
MartinBraquet
ec7c77fcf9 Log 2025-10-02 15:14:13 +02:00
MartinBraquet
46a338b874 Clean 2025-10-02 14:54:47 +02:00
MartinBraquet
bfee7ff09d Fix age rendering 2025-10-02 14:14:24 +02:00
MartinBraquet
ce1305d8ae Host logos 2025-10-02 13:17:22 +02:00
MartinBraquet
aaebf88438 Log profile count 2025-10-01 09:02:50 +02:00
MartinBraquet
dde2c99e36 Fix 2025-10-01 08:58:11 +02:00
MartinBraquet
4dc2f3b9b9 Add Community Growth over Time 2025-10-01 08:56:03 +02:00
MartinBraquet
f30cfffb86 Fix bio parsing grid 2025-09-30 22:17:28 +02:00
MartinBraquet
ca3eb62ba7 Fix 2 2025-09-30 21:48:15 +02:00
MartinBraquet
c8e55ca4ce Fix 2025-09-30 21:46:31 +02:00
MartinBraquet
e4acb25a40 Better render headings and lists in profile grid 2025-09-30 21:45:09 +02:00
MartinBraquet
c741e10139 Stop spamming prod discord channels 2025-09-30 21:30:38 +02:00
MartinBraquet
28d0b35f8e Move discord move to create profile 2025-09-30 20:53:26 +02:00
MartinBraquet
f7f09cd9e5 Send message to Discord when reaching 50, 100, ..., users 2025-09-28 21:09:11 +02:00
MartinBraquet
501c92c350 Send discord message at every profile creation 2025-09-28 20:47:31 +02:00
MartinBraquet
f021101322 Remove unused React 2025-09-26 22:17:24 +02:00
MartinBraquet
369265bc2c Add firebase storage backup script 2025-09-26 22:12:08 +02:00
Martin Braquet
b1f1e5db1f Fully delete profile in database and Firebase auth + storage (#7) 2025-09-26 22:10:38 +02:00
MartinBraquet
51d32e5afb Link donations in FAQ 2025-09-26 13:52:40 +02:00
MartinBraquet
f396e8e482 Your filters 2025-09-26 13:51:08 +02:00
MartinBraquet
077321731e Add bio warning message 2025-09-25 23:02:04 +02:00
MartinBraquet
60eb0c6978 Add 'datingdoc', 'friendshipdoc', 'connectiondoc', 'workdoc' 2025-09-25 22:49:54 +02:00
MartinBraquet
475f0af78a Add supabase backup VM to Firebase storage and discord error hook 2025-09-24 15:59:46 +02:00
MartinBraquet
206fa07035 Ignore tf 2025-09-24 15:13:33 +02:00
MartinBraquet
aff949714c Add charts example 2025-09-24 13:28:46 +02:00
Martin Braquet
7e834b9ff6 Update FUNDING.yml 2025-09-24 12:46:20 +02:00
Martin Braquet
19bad26a98 Create FUNDING.yml 2025-09-24 12:44:37 +02:00
MartinBraquet
7cc7c8d27b Hide age, city and gender if null 2025-09-22 11:06:52 +02:00
MartinBraquet
ae5a8c7cfa Add page loading warning 2025-09-22 11:02:53 +02:00
MartinBraquet
5004b73210 Fix calendly link 2025-09-22 10:52:46 +02:00
MartinBraquet
02f613d269 Add Ko-fi donation link 2025-09-22 10:42:23 +02:00
MartinBraquet
439ac0310b Add option to delete an answered compatibility prompt 2025-09-22 10:09:13 +02:00
MartinBraquet
3e95467819 Add okcupid and calendly links 2025-09-22 00:06:05 +02:00
MartinBraquet
90522cb88b Comment log 2025-09-22 00:05:15 +02:00
MartinBraquet
af39b01d4a Reload env config after setting env vars 2025-09-21 16:56:42 +02:00
MartinBraquet
73a0a5ff0b Fix vercel env 2025-09-21 16:08:29 +02:00
MartinBraquet
e157f500bc Fix dev firebase admin key 2025-09-21 16:00:29 +02:00
MartinBraquet
274ee5ed5f Remove json 2025-09-21 15:44:33 +02:00
MartinBraquet
4cb11ba8c0 Remove log 2025-09-21 15:00:26 +02:00
MartinBraquet
7b8e775139 Fix google cloud env 2025-09-21 14:55:32 +02:00
MartinBraquet
86a7d26bfd Clean readme 2025-09-20 23:54:56 +02:00
MartinBraquet
84a437772d Make local DEV work out of the box 2025-09-20 23:51:28 +02:00
MartinBraquet
d7c95e2ae0 Clean ENV 2025-09-20 18:26:03 +02:00
MartinBraquet
b4f0ef8b43 Move supabase dev pwd inside code 2025-09-20 18:12:46 +02:00
MartinBraquet
6d30cd7ae4 Add open source to FAQ 2025-09-19 14:47:58 +02:00
MartinBraquet
f631236ee7 Add platform to FAQ 2025-09-19 14:42:36 +02:00
MartinBraquet
1a58ff5c4c Shuffle prompts 2025-09-18 15:33:23 +02:00
MartinBraquet
73aca913a1 Fix 2025-09-18 14:14:58 +02:00
MartinBraquet
24dee0cad6 Add bio char template 2025-09-18 13:34:29 +02:00
MartinBraquet
2d2de75372 Add bio tips 2025-09-18 13:12:48 +02:00
MartinBraquet
d98982e6fd Factor out links 2025-09-18 11:30:59 +02:00
MartinBraquet
14c12ffb08 Rename 2025-09-18 11:19:09 +02:00
MartinBraquet
f260afca11 Ignore 2025-09-18 11:18:22 +02:00
MartinBraquet
5bcbe25d97 Rm 2025-09-18 11:18:04 +02:00
MartinBraquet
2eee366fbd Update financials 2025-09-18 11:12:33 +02:00
MartinBraquet
85d57ec5e6 Fix resend email limit 2/sec 2025-09-17 18:35:29 +02:00
MartinBraquet
502c878f82 Fix 2025-09-17 18:15:02 +02:00
237 changed files with 2610 additions and 1174 deletions

View File

@@ -1,20 +1,7 @@
# Rename this file to `.env` and fill in the values.
# You already have access to basic local functionality (UI, authentication, database read access).
# Optional variables for the backend server functionality (modifying user data, etc.)
# For database write access (dev).
# A 16-character password with digits and letters.
SUPABASE_DB_PASSWORD=09wATRREfAzyL5pc
# For Firebase access.
# Open a GitHub issue with your contribution ideas and an admin will give you the key.
# TODO: find a way to give anyone moderate access to dev firebase.
GOOGLE_APPLICATION_CREDENTIALS_DEV="[...].json"
# The URL where your local backend server is running.
# You can change the port if needed.
NEXT_PUBLIC_API_URL=localhost:8088
# openssl enc -aes-256-cbc -salt -pbkdf2 -iter 100000 -in backend/shared/src/googleApplicationCredentials-dev.json -out secrets/googleApplicationCredentials-dev.json.enc
GOOGLE_CREDENTIALS_ENC_PWD=nP7s3274uzOG4c2t
# Optional variables for full local functionality
@@ -23,10 +10,6 @@ NEXT_PUBLIC_API_URL=localhost:8088
# Create a free account at https://rapidapi.com/wirefreethought/api/geodb-cities and get an API key.
GEODB_API_KEY=
# For analytics like page views, user actions, feature usage, etc.
# Create a free account at https://posthog.com and get a project API key. Should start with "phc_".
POSTHOG_KEY=
# For sending emails (e.g. for user sign up, password reset, notifications, etc.).
# Create a free account at https://resend.com and get an API key. Should start with "re_".
RESEND_API_KEY=
RESEND_KEY=

15
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: CompassMeet # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: compassconnections # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

7
.gitignore vendored
View File

@@ -79,3 +79,10 @@ email-preview
*.zip
*.tar.gz
*.rar
/favicon_color.ico
/backend/shared/src/googleApplicationCredentials-dev.json
*.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.)
@@ -100,20 +117,17 @@ yarn install
### Environment Variables
Almost all the features will work out of the box, so you can skip this step and come back later if you need to test the following services: email, geolocation.
We can't make the following information public, for security and privacy reasons:
- Database, otherwise anyone could access all the user data (including private messages)
- Firebase, otherwise anyone could remove users or modify the media files
- Email, analytics, and location services, otherwise anyone could use our paid plan
- Email, analytics, and location services, otherwise anyone could use the service plans Compass paid for and run up the bill.
We separate all those services between production and local development, so that you can code freely without impacting the functioning of the platform.
That's why we separate all those services between production and development environments, so that you can code freely without impacting the functioning of the deployed platform.
Contributors should use the default keys for local development. Production uses a separate environment with stricter rules and private keys that are not shared.
Most of the code will work out of the box. All you need to do is creating an `.env` file as a copy of `.env.example`:
```bash
cp .env.example .env
```
If you do need one of the few remaining services, you need to store your own secrets as environment variables. To do so, simply open `.env` and fill in the variables according to the instructions in the file.
If you do need one of the few remaining services, you need to set them up and store your own secrets as environment variables. To do so, simply open `.env` and fill in the variables according to the instructions in the file.
### Tests
@@ -132,6 +146,8 @@ yarn dev
Once the server is running, visit http://localhost:3000 to start using the app. You can sign up and visit the profiles; you should see a few synthetic profiles.
Note: it's normal if page loading locally is much slower than the deployed version. It can take up to 10 seconds, it would be great to improve that though!
### Contributing
Now you can start contributing by making changes and submitting pull requests!

View File

View File

View File

View File

@@ -1,6 +1,5 @@
'use client';
import {aColor, supportEmail} from "@/lib/client/constants";
import Image from 'next/image';
export default function PrivacyPage() {

View File

View File

@@ -0,0 +1,2 @@
'use client';

View File

View File

View File

View File

View File

View File

View File

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

@@ -108,7 +108,7 @@ swaggerDocument.info = {
version: "1.0.0",
contact: {
name: "Compass",
email: "compass.meet.info@gmail.com",
email: "hello@compassmeet.com",
url: "https://compassmeet.com"
}
};
@@ -191,7 +191,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"),

View File

@@ -7,6 +7,31 @@ import { track } from 'shared/analytics'
import { updateUser } from 'shared/supabase/users'
import { tryCatch } from 'common/util/try-catch'
import { insert } from 'shared/supabase/utils'
import {sendDiscordMessage} from "common/discord/core";
function extractTextFromBio(bio: any): 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 ''
}
}
export const createProfile: APIHandler<'create-profile'> = async (body, auth) => {
const pg = createSupabaseDirectClient()
@@ -28,7 +53,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 })
@@ -40,7 +65,51 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
}
log('Created user', data)
await track(user.id, 'create profile', { username: user.username })
return data
const continuation = async () => {
try {
await track(auth.uid, 'create profile', {username: user.username})
} catch (e) {
console.error('Failed to track create profile', e)
}
try {
let message: string = `[**${user.name}**](https://www.compassmeet.com/${user.username}) just created a profile`
if (body.bio) {
const bioText = extractTextFromBio(body.bio)
if (bioText) message += `\n > ${bioText}`
}
await sendDiscordMessage(message, 'members')
} catch (e) {
console.error('Failed to send discord new profile', e)
}
try {
const nProfiles = await pg.one<number>(
`SELECT count(*) FROM profiles`,
[],
(r) => Number(r.count)
)
const isMilestone = (n: number) => {
return (
[15, 20, 30, 40].includes(n) || // early milestones
n % 50 === 0
)
}
console.debug(nProfiles, isMilestone(nProfiles))
if (isMilestone(nProfiles)) {
await sendDiscordMessage(
`We just reached **${nProfiles}** total profiles! 🎉`,
'general',
)
}
} catch (e) {
console.error('Failed to send discord user milestone', e)
}
}
return {
result: data,
continue: continuation,
}
}

View File

@@ -1,27 +1,26 @@
import * as admin from 'firebase-admin'
import { PrivateUser } from 'common/user'
import { randomString } from 'common/util/random'
import { cleanDisplayName, cleanUsername } from 'common/util/clean-username'
import { getIp, track } from 'shared/analytics'
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 { getStorage } from 'firebase-admin/storage'
import { DEV_CONFIG } from 'common/envs/dev'
import { PROD_CONFIG } from 'common/envs/prod'
import { RESERVED_PATHS } from 'common/envs/constants'
import { log, isProd, getUser, getUserByUsername } from 'shared/utils'
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { insert } from 'shared/supabase/utils'
import { convertPrivateUser, convertUser } from 'common/supabase/users'
import {PrivateUser} from 'common/user'
import {randomString} from 'common/util/random'
import {cleanDisplayName, cleanUsername} from 'common/util/clean-username'
import {getIp, track} from 'shared/analytics'
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 {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,
auth,
req
) => {
const { deviceToken: preDeviceToken } = props
const {deviceToken: preDeviceToken} = props
const firebaseUser = await admin.auth().getUser(auth.uid)
const testUserAKAEmailPasswordUser =
@@ -52,7 +51,7 @@ export const createUser: APIHandler<'create-user'> = async (
const rawName = fbUser.displayName || emailName || 'User' + randomString(4)
const name = cleanDisplayName(rawName)
const bucket = getStorage().bucket(getStorageBucketId())
const bucket = getBucket()
const avatarUrl = fbUser.photoURL
? fbUser.photoURL
: await generateAvatarUrl(auth.uid, name, bucket)
@@ -63,7 +62,9 @@ export const createUser: APIHandler<'create-user'> = async (
// Check username case-insensitive
const dupes = await pg.one<number>(
`select count(*) from users where username ilike $1`,
`select count(*)
from users
where username ilike $1`,
[username],
(r) => r.count
)
@@ -71,7 +72,7 @@ export const createUser: APIHandler<'create-user'> = async (
const isReservedName = RESERVED_PATHS.includes(username)
if (usernameExists || isReservedName) username += randomString(4)
const { user, privateUser } = await pg.tx(async (tx) => {
const {user, privateUser} = await pg.tx(async (tx) => {
const preexistingUser = await getUser(auth.uid, tx)
if (preexistingUser)
throw new APIError(403, 'User already exists', {
@@ -81,13 +82,13 @@ export const createUser: APIHandler<'create-user'> = async (
// Check exact username to avoid problems with duplicate requests
const sameNameUser = await getUserByUsername(username, tx)
if (sameNameUser)
throw new APIError(403, 'Username already taken', { username })
throw new APIError(403, 'Username already taken', {username})
const user = removeUndefinedProps({
avatarUrl,
isBannedFromPosting: Boolean(
(deviceToken && bannedDeviceTokens.includes(deviceToken)) ||
(ip && bannedIpAddresses.includes(ip))
(ip && bannedIpAddresses.includes(ip))
),
link: {},
})
@@ -120,10 +121,19 @@ export const createUser: APIHandler<'create-user'> = async (
}
})
log('created user ', { username: user.username, firebaseId: auth.uid })
log('created user ', {username: user.username, firebaseId: auth.uid})
const continuation = async () => {
await track(auth.uid, 'create profile', { username: user.username })
try {
await track(auth.uid, 'create profile', {username: user.username})
} catch (e) {
console.error('Failed to track create profile', e)
}
try {
if (!IS_LOCAL) await sendWelcomeEmail(user, privateUser)
} catch (e) {
console.error('Failed to sendWelcomeEmail', e)
}
}
return {
@@ -135,12 +145,6 @@ export const createUser: APIHandler<'create-user'> = async (
}
}
function getStorageBucketId() {
return isProd()
? PROD_CONFIG.firebaseConfig.storageBucket
: DEV_CONFIG.firebaseConfig.storageBucket
}
// Automatically ban users with these device tokens or ip addresses.
const bannedDeviceTokens = [
'fa807d664415',

View File

@@ -1,11 +1,11 @@
import { getUser } from 'shared/utils'
import { APIError, APIHandler } from './helpers/endpoint'
import { updatePrivateUser, updateUser } from 'shared/supabase/users'
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { FieldVal } from 'shared/supabase/utils'
import {getUser} from 'shared/utils'
import {APIError, APIHandler} from './helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import * as admin from "firebase-admin";
import {deleteUserFiles} from "shared/firebase-utils";
export const deleteMe: APIHandler<'me/delete'> = async (body, auth) => {
const { username } = body
const {username} = body
const user = await getUser(auth.uid)
if (!user) {
throw new APIError(401, 'Your account was not found')
@@ -16,13 +16,27 @@ export const deleteMe: APIHandler<'me/delete'> = async (body, auth) => {
`Incorrect username. You are logged in as ${user.username}. Are you sure you want to delete this account?`
)
}
const userId = user.id
if (!userId) {
throw new APIError(400, 'Invalid user ID')
}
// Remove user data from Supabase
const pg = createSupabaseDirectClient()
await updateUser(pg, auth.uid, {
userDeleted: true,
isBannedFromPosting: true,
})
await updatePrivateUser(pg, auth.uid, {
email: FieldVal.delete(),
})
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])
// May need to also delete from other tables in the future (such as messages, compatibility responses, etc.)
// Delete user files from Firebase Storage
await deleteUserFiles(user.username)
// Remove user from Firebase Auth
try {
const auth = admin.auth()
await auth.deleteUser(userId)
console.debug(`Deleted user ${userId} from Firebase Auth and Supabase`)
} catch (e) {
console.error('Error deleting user from Firebase Auth:', e)
}
}

View File

@@ -2,12 +2,21 @@ import { type APIHandler } from 'api/helpers/endpoint'
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { Row } from 'common/supabase/utils'
export function shuffle<T>(array: T[]): T[] {
const arr = [...array]; // copy to avoid mutating the original
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
export const getCompatibilityQuestions: APIHandler<
'get-compatibility-questions'
> = async (_props, _auth) => {
const pg = createSupabaseDirectClient()
const questions = await pg.manyOrNone<
const dbQuestions = await pg.manyOrNone<
Row<'love_questions'> & { answer_count: number; score: number }
>(
`SELECT
@@ -28,11 +37,12 @@ export const getCompatibilityQuestions: APIHandler<
[]
)
if (false)
console.log(
'got questions',
questions.map((q) => q.question + ' ' + q.score)
)
const questions = shuffle(dbQuestions)
// console.debug(
// 'got questions',
// questions.map((q) => q.question + ' ' + q.score)
// )
return {
status: 'success',

View File

@@ -4,7 +4,7 @@ import {createSupabaseDirectClient} from 'shared/supabase/init'
import {from, join, 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,
@@ -19,6 +19,7 @@ export type profileQueryType = {
wants_kids_strength?: number | undefined,
has_kids?: number | undefined,
is_smoker?: boolean | undefined,
shortBio?: boolean | undefined,
geodbCityIds?: String[] | undefined,
compatibleWithUserId?: string | undefined,
skipId?: string | undefined,
@@ -29,7 +30,7 @@ export type profileQueryType = {
export const loadProfiles = async (props: profileQueryType) => {
const pg = createSupabaseDirectClient()
console.log(props)
console.debug(props)
const {
limit: limitParam,
after,
@@ -42,6 +43,7 @@ export const loadProfiles = async (props: profileQueryType) => {
wants_kids_strength,
has_kids,
is_smoker,
shortBio,
geodbCityIds,
compatibleWithUserId,
orderBy: orderByParam = 'created_time',
@@ -81,13 +83,14 @@ 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))) &&
((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)
@@ -106,7 +109,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}
)),
@@ -153,12 +156,14 @@ export const loadProfiles = async (props: profileQueryType) => {
{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

@@ -1,8 +1,6 @@
import { sign } from 'jsonwebtoken'
import { APIError, APIHandler } from './helpers/endpoint'
import { DEV_CONFIG } from 'common/envs/dev'
import { PROD_CONFIG } from 'common/envs/prod'
import { isProd } from 'shared/utils'
import {sign} from 'jsonwebtoken'
import {APIError, APIHandler} from './helpers/endpoint'
import {ENV_CONFIG} from "common/envs/constants";
export const getSupabaseToken: APIHandler<'get-supabase-token'> = async (
_,
@@ -12,21 +10,17 @@ export const getSupabaseToken: APIHandler<'get-supabase-token'> = async (
if (jwtSecret == null) {
throw new APIError(500, "No SUPABASE_JWT_SECRET; couldn't sign token.")
}
const instanceId = isProd()
? PROD_CONFIG.supabaseInstanceId
: DEV_CONFIG.supabaseInstanceId
const instanceId = ENV_CONFIG.supabaseInstanceId
if (!instanceId) {
throw new APIError(500, 'No Supabase instance ID in config.')
}
const payload = { role: 'anon' } // postgres role
const payload = {role: 'anon'} // postgres role
return {
jwt: sign(payload, jwtSecret, {
algorithm: 'HS256', // same as what supabase uses for its auth tokens
expiresIn: '1d',
audience: instanceId,
issuer: isProd()
? PROD_CONFIG.firebaseConfig.projectId
: DEV_CONFIG.firebaseConfig.projectId,
issuer: ENV_CONFIG.firebaseConfig.projectId,
subject: auth.uid,
}),
}

View File

@@ -1,35 +1,29 @@
import * as admin from 'firebase-admin'
import { z } from 'zod'
import { Request, Response, NextFunction } from 'express'
import {z} from 'zod'
import {NextFunction, Request, Response} from 'express'
import { PrivateUser } from 'common/user'
import { APIError } from 'common/api/utils'
export { APIError } from 'common/api/utils'
import {
API,
APIPath,
APIResponseOptionalContinue,
APISchema,
ValidatedAPIParams,
} from 'common/api/schema'
import { log } from 'shared/utils'
import { getPrivateUserByKey } from 'shared/utils'
import {PrivateUser} from 'common/user'
import {APIError} from 'common/api/utils'
import {API, APIPath, APIResponseOptionalContinue, APISchema, ValidatedAPIParams,} from 'common/api/schema'
import {getPrivateUserByKey, log} from 'shared/utils'
export type Json = Record<string, unknown> | Json[]
export type JsonHandler<T extends Json> = (
req: Request,
res: Response
) => Promise<T>
export type AuthedHandler<T extends Json> = (
req: Request,
user: AuthedUser,
res: Response
) => Promise<T>
export type MaybeAuthedHandler<T extends Json> = (
req: Request,
user: AuthedUser | undefined,
res: Response
) => Promise<T>
export {APIError} from 'common/api/utils'
// export type Json = Record<string, unknown> | Json[]
// export type JsonHandler<T extends Json> = (
// req: Request,
// res: Response
// ) => Promise<T>
// export type AuthedHandler<T extends Json> = (
// req: Request,
// user: AuthedUser,
// res: Response
// ) => Promise<T>
// export type MaybeAuthedHandler<T extends Json> = (
// req: Request,
// user: AuthedUser | undefined,
// res: Response
// ) => Promise<T>
export type AuthedUser = {
uid: string
@@ -39,6 +33,29 @@ type JwtCredentials = { kind: 'jwt'; data: admin.auth.DecodedIdToken }
type KeyCredentials = { kind: 'key'; data: string }
type Credentials = JwtCredentials | KeyCredentials
// export async function verifyIdToken(payload: string): Promise<DecodedIdToken> {
// TODO: make local dev work without firebase admin SDK setup.
// if (IS_LOCAL) {
// // Skip real verification locally (to avoid needing to set up admin service account).
// return {
// aud: "",
// auth_time: 0,
// email_verified: false,
// exp: 0,
// firebase: {identities: {}, sign_in_provider: ""},
// iat: 0,
// iss: "",
// phone_number: "",
// picture: "",
// sub: "",
// uid: 'dev-user',
// user_id: 'dev-user',
// email: 'dev-user@example.com'
// };
// }
// return await admin.auth().verifyIdToken(payload);
// }
export const parseCredentials = async (req: Request): Promise<Credentials> => {
const auth = admin.auth()
const authHeader = req.get('Authorization')
@@ -57,14 +74,14 @@ export const parseCredentials = async (req: Request): Promise<Credentials> => {
throw new APIError(401, 'Firebase JWT payload undefined.')
}
try {
return { kind: 'jwt', data: await auth.verifyIdToken(payload) }
return {kind: 'jwt', data: await auth.verifyIdToken(payload)}
} catch (err) {
// This is somewhat suspicious, so get it into the firebase console
console.error('Error verifying Firebase JWT: ', err, scheme, payload)
throw new APIError(500, 'Error validating token.')
}
case 'Key':
return { kind: 'key', data: payload }
return {kind: 'key', data: payload}
default:
throw new APIError(401, 'Invalid auth scheme; must be "Key" or "Bearer".')
}
@@ -76,7 +93,7 @@ export const lookupUser = async (creds: Credentials): Promise<AuthedUser> => {
if (typeof creds.data.user_id !== 'string') {
throw new APIError(401, 'JWT must contain user ID.')
}
return { uid: creds.data.user_id, creds }
return {uid: creds.data.user_id, creds}
}
case 'key': {
const key = creds.data
@@ -84,7 +101,7 @@ export const lookupUser = async (creds: Credentials): Promise<AuthedUser> => {
if (!privateUser) {
throw new APIError(401, `No private user exists with API key ${key}.`)
}
return { uid: privateUser.id, creds: { privateUser, ...creds } }
return {uid: privateUser.id, creds: {privateUser, ...creds}}
}
default:
throw new APIError(401, 'Invalid credential type.')
@@ -109,45 +126,45 @@ export const validate = <T extends z.ZodTypeAny>(schema: T, val: unknown) => {
}
}
export const jsonEndpoint = <T extends Json>(fn: JsonHandler<T>) => {
return async (req: Request, res: Response, next: NextFunction) => {
try {
res.status(200).json(await fn(req, res))
} catch (e) {
next(e)
}
}
}
export const authEndpoint = <T extends Json>(fn: AuthedHandler<T>) => {
return async (req: Request, res: Response, next: NextFunction) => {
try {
const authedUser = await lookupUser(await parseCredentials(req))
res.status(200).json(await fn(req, authedUser, res))
} catch (e) {
next(e)
}
}
}
export const MaybeAuthedEndpoint = <T extends Json>(
fn: MaybeAuthedHandler<T>
) => {
return async (req: Request, res: Response, next: NextFunction) => {
let authUser: AuthedUser | undefined = undefined
try {
authUser = await lookupUser(await parseCredentials(req))
} catch {
// it's treated as an anon request
}
try {
res.status(200).json(await fn(req, authUser, res))
} catch (e) {
next(e)
}
}
}
// export const jsonEndpoint = <T extends Json>(fn: JsonHandler<T>) => {
// return async (req: Request, res: Response, next: NextFunction) => {
// try {
// res.status(200).json(await fn(req, res))
// } catch (e) {
// next(e)
// }
// }
// }
//
// export const authEndpoint = <T extends Json>(fn: AuthedHandler<T>) => {
// return async (req: Request, res: Response, next: NextFunction) => {
// try {
// const authedUser = await lookupUser(await parseCredentials(req))
// res.status(200).json(await fn(req, authedUser, res))
// } catch (e) {
// next(e)
// }
// }
// }
//
// export const MaybeAuthedEndpoint = <T extends Json>(
// fn: MaybeAuthedHandler<T>
// ) => {
// return async (req: Request, res: Response, next: NextFunction) => {
// let authUser: AuthedUser | undefined = undefined
// try {
// authUser = await lookupUser(await parseCredentials(req))
// } catch {
// // it's treated as an anon request
// }
//
// try {
// res.status(200).json(await fn(req, authUser, res))
// } catch (e) {
// next(e)
// }
// }
// }
export type APIHandler<N extends APIPath> = (
props: ValidatedAPIParams<N>,
@@ -161,7 +178,7 @@ export const typedEndpoint = <N extends APIPath>(
name: N,
handler: APIHandler<N>
) => {
const { props: propSchema, authed: authRequired, method } = API[name]
const {props: propSchema, authed: authRequired, method} = API[name]
return async (req: Request, res: Response, next: NextFunction) => {
let authUser: AuthedUser | undefined = undefined
@@ -195,7 +212,7 @@ export const typedEndpoint = <N extends APIPath>(
// Convert bigint to number, b/c JSON doesn't support bigint.
const convertedResult = deepConvertBigIntToNumber(result)
res.status(200).json(convertedResult ?? { success: true })
res.status(200).json(convertedResult ?? {success: true})
}
if (hasContinue) {

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

@@ -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,20 @@ 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 props = {
...row.search_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 +79,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

@@ -1,14 +1,14 @@
import * as admin from 'firebase-admin'
import {getLocalEnv, initAdmin} from 'shared/init-admin'
import {loadSecretsToEnv, getServiceAccountCredentials} from 'common/secrets'
import {initAdmin} from 'shared/init-admin'
import {loadSecretsToEnv} from 'common/secrets'
import {log} from 'shared/utils'
import {LOCAL_DEV} from "common/envs/constants";
import {IS_LOCAL} from "common/envs/constants";
import {METRIC_WRITER} from 'shared/monitoring/metric-writer'
import {listen as webSocketListen} from 'shared/websockets/server'
log('Api server starting up....')
if (LOCAL_DEV) {
if (IS_LOCAL) {
initAdmin()
} else {
const projectId = process.env.GOOGLE_CLOUD_PROJECT
@@ -21,9 +21,10 @@ if (LOCAL_DEV) {
METRIC_WRITER.start()
import {app} from './app'
import {getServiceAccountCredentials} from "shared/firebase-utils";
const credentials = LOCAL_DEV
? getServiceAccountCredentials(getLocalEnv())
const credentials = IS_LOCAL
? getServiceAccountCredentials()
: // No explicit credentials needed for deployed service.
undefined
@@ -37,6 +38,5 @@ const startupProcess = async () => {
})
webSocketListen(httpServer, '/ws')
log('Server started successfully')
}
startupProcess()
startupProcess().then(r => log('Server started successfully'))

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

@@ -5,6 +5,10 @@ import {
} from 'resend'
import { log } from 'shared/utils'
import pLimit from 'p-limit'
const limit = pLimit(1) // 1 concurrent per second
/*
* typically: { subject: string, to: string | string[] } & ({ text: string } | { react: ReactNode })
*/
@@ -13,12 +17,18 @@ export const sendEmail = async (
options?: CreateEmailRequestOptions
) => {
const resend = getResend()
console.log(resend, payload, options)
const { data, error } = await resend.emails.send(
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))
}
const { data, error } = await sendEmailThrottle(
{ replyTo: 'Compass <no-reply@compassmeet.com>', ...payload },
options
)
console.log('resend.emails.send', data, error)
console.debug('resend.emails.send', data, error)
if (error) {
log.error(
@@ -36,8 +46,13 @@ let resend: Resend | null = null
const getResend = () => {
if (resend) return resend
if (!process.env.RESEND_KEY) {
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,4 +1,6 @@
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 {
email?: string
@@ -13,36 +15,36 @@ export const Footer = ({
<hr style={{border: 'none', borderTop: '1px solid #e0e0e0', margin: '10px 0'}}/>
<Row>
<Column align="center">
<Link href="https://github.com/CompassConnections/Compass" target="_blank">
<Link href={githubRepo} target="_blank">
<Img
src="https://cdn-icons-png.flaticon.com/512/733/733553.png"
src={`https://${DOMAIN}/images/github-logo.png`}
width="24"
height="24"
alt="GitHub"
style={{ display: "inline-block", margin: "0 4px" }}
/>
</Link>
<Link href="https://discord.gg/8Vd7jzqjun" target="_blank">
<Link href={discordLink} target="_blank">
<Img
src="https://cdn-icons-png.flaticon.com/512/2111/2111370.png"
src={`https://${DOMAIN}/images/discord-logo.png`}
width="24"
height="24"
alt="Discord"
style={{ display: "inline-block", margin: "0 4px" }}
/>
</Link>
<Link href="https://patreon.com/CompassMeet" target="_blank">
<Link href={patreonLink} target="_blank">
<Img
src="https://static.vecteezy.com/system/resources/previews/027/127/454/non_2x/patreon-logo-patreon-icon-transparent-free-png.png"
src={`https://${DOMAIN}/images/patreon-logo.png`}
width="24"
height="24"
alt="Patreon"
style={{ display: "inline-block", margin: "0 4px" }}
/>
</Link>
<Link href="https://www.paypal.com/paypalme/CompassConnections" target="_blank">
<Link href={paypalLink} target="_blank">
<Img
src="https://cdn-icons-png.flaticon.com/512/174/174861.png"
src={`https://${DOMAIN}/images/paypal-logo.png`}
width="24"
height="24"
alt="PayPal"

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

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