140 Commits
1.1.1 ... 1.1.2

Author SHA1 Message Date
MartinBraquet
1136c3f767 Release 2025-09-17 17:58:59 +02:00
MartinBraquet
42b496cd77 Fix 2025-09-17 17:58:11 +02:00
MartinBraquet
4acb5ee020 Fix warnings 2025-09-17 15:56:17 +02:00
MartinBraquet
ea18781cc6 Rename lover -> profile 2025-09-17 15:51:19 +02:00
MartinBraquet
593617c0ff Rename Lover -> Profile 2025-09-17 15:43:19 +02:00
MartinBraquet
c6a139d88d Rename Lovers -> Profiles 2025-09-17 15:42:23 +02:00
MartinBraquet
b7357a4546 Set 3 words 2025-09-17 15:25:27 +02:00
MartinBraquet
5eac959d15 FIx paypal path 2025-09-17 15:20:49 +02:00
MartinBraquet
74c86ecfbe FIx path 2025-09-17 15:19:17 +02:00
MartinBraquet
f353e590e1 Rename lovers -> profiles 2025-09-17 15:11:53 +02:00
MartinBraquet
a4cc3e10c2 Upgrade keywords examples 2025-09-17 12:00:24 +02:00
MartinBraquet
7321f56ee2 Upgrade docs 2025-09-16 23:49:17 +02:00
MartinBraquet
8800d9adc6 Upgrade README.md 2025-09-16 23:37:45 +02:00
MartinBraquet
22cd535527 Upgrade README.md 2025-09-16 23:35:02 +02:00
MartinBraquet
1d0e9592df Replace github sponsors 2025-09-16 22:53:53 +02:00
MartinBraquet
2ef4af0ff2 Fix links submit your own 2025-09-16 22:49:53 +02:00
MartinBraquet
542a6b1592 Fix endpoint 2025-09-16 22:42:37 +02:00
MartinBraquet
613ef94dba Upgrade docs endpoint 2025-09-16 22:28:40 +02:00
MartinBraquet
1dc2a1fadf Fix 2025-09-16 22:12:19 +02:00
MartinBraquet
41a606f5c1 Fix ages in filters 2025-09-16 22:09:43 +02:00
MartinBraquet
7b2b9855f9 Fix notifs page 2025-09-16 21:54:29 +02:00
MartinBraquet
b2b519ba2e Clean 2025-09-16 21:38:52 +02:00
MartinBraquet
5cf89392ff Fix username not being updated when loading their profile after registration 2025-09-16 21:38:19 +02:00
MartinBraquet
0f05304ec3 Fix typo 2025-09-16 21:07:02 +02:00
MartinBraquet
87bc962c88 Fix Rename github org 2025-09-16 18:59:21 +02:00
MartinBraquet
546ce6e229 Revert "Rename github org"
This reverts commit 2163d5aaf6.
2025-09-16 18:58:29 +02:00
MartinBraquet
2163d5aaf6 Rename github org 2025-09-16 18:49:36 +02:00
MartinBraquet
905ea160f2 Fix link 2025-09-16 18:42:48 +02:00
MartinBraquet
675f4a372b Update paypal links 2025-09-16 18:41:32 +02:00
MartinBraquet
7ff42db0c6 Curl API 2025-09-16 18:41:25 +02:00
MartinBraquet
a01283a446 Fix links 2025-09-16 18:18:57 +02:00
MartinBraquet
fefa261e7d Restrict matched users to last 24h 2025-09-16 18:09:48 +02:00
MartinBraquet
0447b22dd2 Restrict internal/send-search-notifications with API key 2025-09-16 17:54:13 +02:00
MartinBraquet
cf125c1b48 Fix packages 2025-09-16 16:48:52 +02:00
MartinBraquet
81a9d8257c Trigger filter change only on slide commit to avoid API overload 2025-09-16 16:15:07 +02:00
MartinBraquet
ee3f471300 Skip 2025-09-16 16:14:39 +02:00
MartinBraquet
5c2e5f626d Upgrade subject 2025-09-16 16:14:34 +02:00
MartinBraquet
a0f4b62361 Allow for empty orderBy 2025-09-16 16:14:26 +02:00
MartinBraquet
786166b448 Fix connection clause 2025-09-16 16:14:16 +02:00
MartinBraquet
66e198b4ef Ignore 2025-09-16 15:13:40 +02:00
MartinBraquet
4919240242 Update readme info 2025-09-16 15:13:36 +02:00
MartinBraquet
d7e6a41e3f Clean installs 2025-09-16 14:31:39 +02:00
MartinBraquet
202ef737dd Add react email as dev 2025-09-16 14:08:36 +02:00
MartinBraquet
04993224dc Nice emails 2025-09-16 14:03:08 +02:00
MartinBraquet
bebe7c28f8 Fix 2025-09-16 12:48:44 +02:00
MartinBraquet
639991dde4 Add bookmarked search emails and factor out utils from web to common 2025-09-16 12:36:18 +02:00
MartinBraquet
31404cb89a Add allowSyntheticDefaultImports 2025-09-16 12:35:19 +02:00
MartinBraquet
f6205ca1dd Add source.sh 2025-09-16 12:35:12 +02:00
MartinBraquet
6e86fc0593 Add build_api.sh 2025-09-16 12:35:01 +02:00
MartinBraquet
f39a9845a3 Fix tsconfig include jsonapi 2025-09-15 21:08:27 +02:00
MartinBraquet
ba17582945 Factor out loadProfiles 2025-09-15 21:08:14 +02:00
MartinBraquet
02a1cbd467 Rename getLovers and add base send-search-notifications.ts 2025-09-15 18:48:34 +02:00
MartinBraquet
2cd102ef0b Add API docs 2025-09-15 18:45:03 +02:00
MartinBraquet
240361b55b Access API at / install of /v0/ 2025-09-15 18:07:25 +02:00
MartinBraquet
9beabc93cd Clean 2025-09-15 16:13:11 +02:00
MartinBraquet
8f4c6b911a Meke age optional in API requests 2025-09-15 16:04:30 +02:00
MartinBraquet
083ef3010d Add location column 2025-09-15 14:24:47 +02:00
MartinBraquet
e6c2253219 Fix location pretty print 2025-09-14 22:45:04 +02:00
MartinBraquet
d802eb3f28 Fix 2025-09-14 22:36:16 +02:00
MartinBraquet
a342d5d5ad Fix hidden close button 2025-09-14 22:35:00 +02:00
MartinBraquet
99adb77fcb Pretty print bookmarked searches 2025-09-14 22:18:21 +02:00
MartinBraquet
2ea4eae9d6 Add wantsKidsNames 2025-09-14 22:16:45 +02:00
MartinBraquet
9b079b2c3a Add hasKidsNames 2025-09-14 22:16:30 +02:00
MartinBraquet
8648e8569e Add list style 2025-09-14 22:16:03 +02:00
MartinBraquet
1be0ab8bcb Fix 2025-09-14 16:37:57 +02:00
MartinBraquet
718f76c1f2 Hide likes 2025-09-14 16:37:52 +02:00
MartinBraquet
155d1f4c06 Add bookmarked filters for notifications 2025-09-13 23:21:45 +02:00
MartinBraquet
cb79e27d5a Fix hover button gray 2025-09-13 23:19:51 +02:00
MartinBraquet
26991f8dd8 Remove blue message 2025-09-13 23:19:29 +02:00
MartinBraquet
2375330d76 Fix modal size on mobile 2025-09-13 23:18:56 +02:00
MartinBraquet
94e9b6d99b Fix UI 2025-09-13 23:18:33 +02:00
MartinBraquet
b516d24101 Fix font 2025-09-13 23:18:11 +02:00
MartinBraquet
1b131d9371 Fix typo 2025-09-13 23:18:01 +02:00
MartinBraquet
3f45ef192d Update db schema 2025-09-13 23:17:46 +02:00
MartinBraquet
c6684af521 Improve colors 2025-09-13 23:17:23 +02:00
MartinBraquet
52f12b81ff Move script 2025-09-13 23:17:10 +02:00
MartinBraquet
6630f787bf Debug log 2025-09-13 23:16:58 +02:00
MartinBraquet
2d7b2da3e2 Improve wording 2025-09-13 23:16:53 +02:00
MartinBraquet
d3b008fcd9 Add bookmarked_searches.sql 2025-09-13 18:12:20 +02:00
MartinBraquet
8a62fd0e6a Add migration 2025-09-13 18:11:57 +02:00
MartinBraquet
b044860f05 Add last_modification_time 2025-09-13 18:11:46 +02:00
MartinBraquet
1c5786dfb6 Add web readme 2025-09-13 16:55:09 +02:00
MartinBraquet
6bc9e3d695 Downgrade next 2025-09-13 16:44:14 +02:00
MartinBraquet
b74fe59f12 Upgrade next 2025-09-13 16:19:23 +02:00
MartinBraquet
6b57aa7f14 Add info 2025-09-13 16:12:05 +02:00
MartinBraquet
227125b35c Update packages 2025-09-13 16:11:59 +02:00
MartinBraquet
c4012d8dfc Fix 2025-09-13 15:38:08 +02:00
MartinBraquet
cf3fa9ffbc Fix 2025-09-13 15:36:04 +02:00
MartinBraquet
40640d029a Update packages 2025-09-13 15:33:23 +02:00
MartinBraquet
01eb7038dc Add /support page 2025-09-13 15:12:45 +02:00
MartinBraquet
58115bfd11 Dynamic filename finding 2025-09-13 15:12:32 +02:00
MartinBraquet
f1ea5031fb Remove supabase token 2025-09-13 14:54:10 +02:00
MartinBraquet
26d15a9fb3 Remove autogenerated line 2025-09-13 13:14:56 +02:00
MartinBraquet
54ba8e6047 Upgrade next 2025-09-13 12:39:24 +02:00
MartinBraquet
eca063ab75 Clean 2025-09-13 12:14:17 +02:00
MartinBraquet
8892f4144e Remove logs 2025-09-13 12:14:09 +02:00
MartinBraquet
d2c25f9d6c Remove firebase warning 2025-09-13 12:03:08 +02:00
MartinBraquet
b57457dc2f Add yarn clean-install 2025-09-13 11:57:41 +02:00
MartinBraquet
2861b0cfa2 Update caniuse 2025-09-13 11:47:58 +02:00
MartinBraquet
0c45dbb884 Fix warning font 2025-09-13 11:46:58 +02:00
MartinBraquet
a9f9261fb7 Update description 2025-09-12 21:42:18 +02:00
MartinBraquet
7e5f54a4f1 Add anim in search bar 2025-09-12 21:40:02 +02:00
MartinBraquet
1228e8759c Add multiple keywords per search 2025-09-12 21:39:44 +02:00
MartinBraquet
1daf771218 Clean file architecture 2025-09-12 20:47:37 +02:00
MartinBraquet
880cb08c3d Update FAQ 2025-09-12 20:17:24 +02:00
MartinBraquet
e2cbae3089 Add supabase dev key 2025-09-12 20:06:20 +02:00
MartinBraquet
42dcc3318c Comment 2025-09-12 18:33:46 +02:00
MartinBraquet
b32a85ae7e Fix cookie 2025-09-12 18:27:39 +02:00
MartinBraquet
af85edddca Ignore pics 2025-09-12 18:18:55 +02:00
MartinBraquet
eccd88e3c2 Debug cookie 2025-09-12 18:14:34 +02:00
MartinBraquet
e0e11629a1 Set up PostHog 2025-09-12 18:03:21 +02:00
MartinBraquet
968095c183 Fix LOCAL_DEV import in shared 2025-09-12 17:40:54 +02:00
MartinBraquet
d32b5115c5 Clean images and SEO 2025-09-12 17:21:03 +02:00
MartinBraquet
d3001ec887 Fix 2025-09-12 17:00:53 +02:00
MartinBraquet
fef6a52008 Fix og url for local 2025-09-12 17:00:49 +02:00
MartinBraquet
048e6affbc Update dev info 2025-09-12 16:42:53 +02:00
MartinBraquet
c653d49691 Update doc 2025-09-12 15:56:42 +02:00
MartinBraquet
6f5c9bd054 Update docs 2025-09-12 15:55:45 +02:00
MartinBraquet
9e5576244d Fix readme 2025-09-12 15:36:23 +02:00
MartinBraquet
ef91317232 Add local dev info 2025-09-12 15:29:17 +02:00
MartinBraquet
10c44d050f Fix 2025-09-12 15:03:44 +02:00
MartinBraquet
1845ea7170 Fix 2025-09-12 15:03:12 +02:00
MartinBraquet
d453294622 Fix 2025-09-12 15:02:47 +02:00
MartinBraquet
d11f9e4971 Fix font 2025-09-12 15:01:57 +02:00
MartinBraquet
08272dd04e Fix typo 2025-09-12 15:01:50 +02:00
MartinBraquet
42441b9b42 Add info 2025-09-12 14:54:06 +02:00
MartinBraquet
e4a293c046 Add todos 2025-09-12 14:50:47 +02:00
MartinBraquet
0cc5a39d63 Add todos 2025-09-12 14:48:32 +02:00
MartinBraquet
942ea3f125 Update readme todo 2025-09-12 14:36:03 +02:00
MartinBraquet
a8a70bb71c Make avatar pic if no pictures 2025-09-12 12:01:04 +02:00
MartinBraquet
0d7c3fb4b2 Allow everyone to message everyone for now 2025-09-12 12:00:41 +02:00
MartinBraquet
77c682454e Reduce like button size 2025-09-12 12:00:15 +02:00
MartinBraquet
dd3473f5d8 Improve prompts link 2025-09-12 03:39:50 +02:00
MartinBraquet
cceadc5e04 Center the icons, even in gmail 2025-09-12 03:15:17 +02:00
MartinBraquet
e48c3a3f9c Add links to emails 2025-09-12 02:42:00 +02:00
MartinBraquet
14981ef077 Fix 2025-09-12 02:15:06 +02:00
MartinBraquet
a7858d44bd Fix empty content 2025-09-12 02:10:19 +02:00
MartinBraquet
9ae5f27c04 Remove free responses for now as not implemented in the db 2025-09-12 02:03:30 +02:00
MartinBraquet
d691129842 Hide location if null 2025-09-12 01:57:22 +02:00
MartinBraquet
e26d551263 Fix overrides 2025-09-12 01:47:11 +02:00
225 changed files with 4745 additions and 4781 deletions

View File

@@ -1,14 +1,16 @@
# Rename this file to `.env` and fill in the values.
# You already have access to basic local functionality (UI, authentication, database read access).
# Required variables for basic local functionality
# Optional variables for the backend server functionality (modifying user data, etc.)
# For database connection. A 16-character password with digits and letters.
SUPABASE_DB_PASSWORD=
NEXT_PUBLIC_SUPABASE_KEY=
# For database write access (dev).
# A 16-character password with digits and letters.
SUPABASE_DB_PASSWORD=09wATRREfAzyL5pc
# For authentication.
# Ask the project admin. Should start with "AIza".
NEXT_PUBLIC_FIREBASE_API_KEY=
# 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.

18
.gitignore vendored
View File

@@ -55,9 +55,27 @@ tsconfig.tsbuildinfo
*prisma/migrations
martin
email-preview
.obsidian
.idea
*.last-run.json
*lock.hcl
/web/pages/test.tsx
*.png
*.jpg
*.jpeg
*.gif
*.svg
*.mp4
*.mov
*.avi
*.wmv
*.mp3
*.wav
*.flac
*.aac
*.zip
*.tar.gz
*.rar

View File

@@ -19,7 +19,7 @@ We welcome pull requests, but only if they meet the project's quality and design
3. **Add the upstream remote**:
```bash
git remote add upstream https://github.com/CompassMeet/Compass.git
git remote add upstream https://github.com/CompassConnections/Compass.git
```
## Create a New Branch

119
README.md
View File

@@ -1,13 +1,13 @@
[![CI](https://github.com/CompassMeet/Compass/actions/workflows/ci.yml/badge.svg)](https://github.com/CompassMeet/Compass/actions/workflows/ci.yml)
[![CD](https://github.com/CompassMeet/Compass/actions/workflows/cd.yml/badge.svg)](https://github.com/CompassMeet/Compass/actions/workflows/cd.yml)
[![CI](https://github.com/CompassConnections/Compass/actions/workflows/ci.yml/badge.svg)](https://github.com/CompassConnections/Compass/actions/workflows/ci.yml)
[![CD](https://github.com/CompassConnections/Compass/actions/workflows/cd.yml/badge.svg)](https://github.com/CompassConnections/Compass/actions/workflows/cd.yml)
![Vercel](https://deploy-badge.vercel.app/vercel/bayesbond)
# Compass
This repository provides the source code for [Compass](https://compassmeet.com), a web application for people to form deep 1-on-1 relationships in a fully transparent and efficient way. And it just got released!
This repository contains the source code for [Compass](https://compassmeet.com) — an open platform for forming deep, authentic 1-on-1 connections with clarity and efficiency.
**We cant do this alone.** Whatever your skills—coding, design, writing, moderation, marketing, or even small donations—you can make a real difference. [Contribute](https://www.compassmeet.com/about) in any way you can and help our community thrive!
**We cant do this alone.** Whatever your skills—coding, design, writing, moderation, marketing, or even small donations—you can make a real difference. [Contribute](https://www.compassmeet.com/support) in any way you can and help our community thrive!
## Features
@@ -18,10 +18,15 @@ This repository provides the source code for [Compass](https://compassmeet.com),
- Open source
- Democratically governed
A detailed description of the vision is available [here](https://martinbraquet.com/meeting-rational).
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).
## 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).
- [x] Authentication (user/password and Google Sign In)
- [x] Set up PostgreSQL in Production with supabase
- [x] Set up web hosting (vercel)
@@ -30,20 +35,36 @@ A detailed description of the vision is available [here](https://martinbraquet.c
- [x] Set up page listing all the profiles
- [x] Search through most profile variables
- [x] Set up chat / direct messaging
- [x] Set up domain name (https://compassmeet.com)
- [x] Set up domain name (compassmeet.com)
- [ ] Add mobile app (React Native on Android and iOS)
- [ ] Add better onboarding (tooltips, modals, etc.)
- [ ] Add modules to learn more about each other (personality test, conflict style, love languages, etc.)
- [ ] Add modules to improve interpersonal skills (active listening, nonviolent communication, etc.)
- [ ] Add calendar integration and scheduling
- [ ] Add events (group calls, in-person meetups, etc.)
#### Secondary To Do
Any action item is open to anyone for collaboration, but the following ones are particularly easy to do for first-time contributors.
Everything is open to anyone for collaboration, but the following ones are particularly easy to do for first-time contributors.
- [x] Clean up learn more page
- [x] Add dark theme
- [ ] Add profile features (intellectual interests, cause areas, personality type, conflict style, etc.)
- [ ] Add filters to search through remaining profile features (politics, religion, education level, etc.)
- [ ] Cover with tests (very important, just the test template and framework are ready)
- [ ] Make the app more user-friendly and appealing (UI/UX)
- [ ] Clean up terms and conditions (convert to Markdown)
- [ ] Clean up privacy notice (convert to Markdown)
- [x] Clean up learn more page
- [x] Add dark theme
- [ ] Add other authentication methods (GitHub, Facebook, Apple, phone, etc.)
- [ ] Add email verification
- [ ] Add password reset
- [ ] 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.)
- [ ] Improve [financials](web/public/md/financials.md) page (donor / acknowledgments, etc.)
- [ ] Improve loading sign (e.g., animation of a compass moving around)
- [ ] Show compatibility score in profile page
## Implementation
@@ -56,13 +77,13 @@ The web app is coded in Typescript using React as front-end. It includes:
## Development
Below are all the steps to contribute. If you have any trouble or questions, please don't hesitate to open an issue or contact us on [Discord](https://discord.gg/8Vd7jzqjun)! We're responsive and happy to help.
Below are the steps to contribute. If you have any trouble or questions, please don't hesitate to open an issue or contact us on [Discord](https://discord.gg/8Vd7jzqjun)! We're responsive and happy to help.
### Installation
Clone the repo and navigating into it:
Fork the [repo](https://github.com/CompassConnections/Compass) on GitHub (button in top right). Then, clone your repo and navigating into it:
```bash
git clone git@github.com:CompassMeet/Compass.git
git clone https://github.com/<your-username>/Compass.git
cd Compass
```
@@ -70,7 +91,7 @@ Install `opentofu`, `docker`, and `yarn`. Try running this on Linux or macOS for
```bash
./setup.sh
```
If it doesn't work, you can install them manually (Google how to install `opentofu`, `docker`, and `yarn` for your OS).
If it doesn't work, you can install them manually (google how to install `opentofu`, `docker`, and `yarn` for your OS).
Then, install the dependencies for this project:
```bash
@@ -84,54 +105,23 @@ We can't make the following information public, for security and privacy reasons
- Firebase, otherwise anyone could remove users or modify the media files
- Email, analytics, and location services, otherwise anyone could use our paid plan
So, for your development, we will give you user-specific access when possible (e.g., Firebase) and for the rest you will need to set up cloned services (email, locations, etc.) and store your secrets as environment variables.
We separate all those services between production and local development, so that you can code freely without impacting the functioning of the 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.
To do so, simply create an `.env` file as a copy of `.env.example`, open it, and fill in the variables according to the instructions in the file:
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
```
### Installing PostgreSQL
Run the following commands to set up your local development database. Run only the section that corresponds to your operating system.
On macOS:
```bash
brew install postgresql
brew services start postgresql
```
On Linux:
```bash
sudo apt update
sudo apt install postgresql postgresql-contrib
sudo systemctl start postgresql
````
On Windows, you can download PostgreSQL from the [official website](https://www.postgresql.org/download/windows/).
### Database Initialization
Create a database named `compass` and set the password for the `postgres` user:
```bash
sudo -u postgres psql
ALTER USER postgres WITH PASSWORD 'password';
\q
```
Create the database
```bash
...
```
Note that your local database will be made of synthetic data, not real users. This is fine for development and testing.
If you do need one of the few remaining services, you need to store your own secrets as environment variables. To do so, simply open `.env` and fill in the variables according to the instructions in the file.
### Tests
Make sure the tests pass:
```bash
yarn test
yarn test tests/jest/
```
TODO: fix tests
TODO: make `yarn test` run all the tests, not just the ones in `tests/jest/`.
### Running the Development Server
@@ -140,11 +130,38 @@ Start the development server:
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 5 synthetic profiles.
Once the server is running, visit http://localhost:3000 to start using the app. You can sign up and visit the profiles; you should see a few synthetic profiles.
### Contributing
Now you can start contributing by making changes and submitting pull requests!
See [development.md](docs/development.md) for additional instructions, such as adding new profile features.
### Submission
Add the original repo as upstream for syncing:
```bash
git remote add upstream https://github.com/CompassConnections/Compass.git
```
Create a new branch for your changes:
```bash
git checkout -b <branch-name>
```
Make changes, then stage and commit:
```bash
git add .
git commit -m "Describe your changes"
```
Push branch to your fork:
```bash
git push origin <branch-name>
```
Finally, open a Pull Request on GitHub from your `fork/<branch-name>``CompassConnections/Compass` main branch.
## Acknowledgements
This project is built on top of [manifold.love](https://github.com/sipec/polylove), an open-source dating platform licensed under the MIT License. We greatly appreciate their work and contributions to open-source, which have significantly aided in the development of some core features such as direct messaging, prompts, and email notifications. We invite the community to explore and contribute to other open-source projects like manifold.love as well, especially if you're interested in functionalities that deviate from Compass' ideals of deep, intentional connections.

View File

@@ -8,5 +8,5 @@
## Reporting a Vulnerability
Contact the development team to report a vulnerability. You should receive updates within a week.
Contact the development team at compass.meet.info@gmail.com to report a vulnerability. You should receive updates within a week.

View File

@@ -1,19 +1,20 @@
# prereq: first do `yarn build` to compile typescript & etc.
FROM node:19-alpine
FROM node:20-alpine
WORKDIR /usr/src/app
# Install PM2 globally
RUN yarn global add pm2
# Remove?
COPY tsconfig.json ./
# first get dependencies in for efficient docker layering
# Fet dependencies in for efficient docker layering
COPY dist/package.json dist/yarn.lock ./
RUN yarn install --frozen-lockfile --production
# then copy over typescript payload
# Clean yarn cache to reduce image size
RUN yarn install --frozen-lockfile --production && \
yarn cache clean --force && \
rm -rf /usr/local/share/.cache/yarn
# Copy over typescript payload
COPY dist ./
# Copy the PM2 ecosystem configuration

View File

@@ -1,33 +1,38 @@
# Backend API
This is the code for the API running at `api.compassmeet.com`.
This is the code for the API running at https://api.compassmeet.com.
It runs in a docker inside a Google Cloud virtual machine.
### Requirements
You must have the `gcloud` CLI.
On MacOS:
On macOS:
```bash
brew install --cask google-cloud-sdk
```
On Linux:
```bash
sudo apt-get update && sudo apt-get install google-cloud-sdk
```
Then:
```bash
gcloud init
gcloud auth login
gcloud config set project YOUR_PROJECT_ID
```
### Setup
This section is only for the people who are creating a server from scratch, for instance for a forked project.
One-time commands you may need to run:
```bash
gcloud artifacts repositories create builds \
--repository-format=docker \
@@ -51,6 +56,20 @@ gcloud projects add-iam-policy-binding compass-130ba \
gcloud run services list
```
Set up the saved search notifications job:
```bash
gcloud scheduler jobs create http daily-saved-search-notifications \
--schedule="0 16 * * *" \
--uri="https://api.compassmeet.com/internal/send-search-notifications" \
--http-method=POST \
--headers="x-api-key=<API_KEY>" \
--time-zone="UTC" \
--location=us-west1
```
View it [here](https://console.cloud.google.com/cloudscheduler).
##### DNS
* After deployment, Terraform assigns a static external IP to this resource.
@@ -60,6 +79,7 @@ gcloud run services list
gcloud compute addresses describe api-lb-ip-2 --global --format="get(address)"
34.117.20.215
```
Since Vercel manages your domain (`compassmeet.com`):
1. Log in to [Vercel dashboard](https://vercel.com/dashboard).
@@ -67,7 +87,7 @@ Since Vercel manages your domain (`compassmeet.com`):
3. Add an **A record** for your API subdomain:
| Type | Name | Value | TTL |
| ---- | ---- | ------------ | ----- |
|------|------|--------------|-------|
| A | api | 34.123.45.67 | 600 s |
* `Name` is just the subdomain: `api``api.compassmeet.com`.
@@ -85,7 +105,6 @@ curl -I https://api.compassmeet.com
* `nslookup` should return the LB IP (`34.123.45.67`).
* `curl -I` should return `200 OK` from your service.
If SSL isnt ready (may take 15 mins), check LB logs:
```bash
@@ -96,7 +115,9 @@ gcloud compute ssl-certificates describe api-lb-cert-2
Secrets are strings that shouldn't be checked into Git (eg API keys, passwords).
Add the secrets for your specific project in [Google Cloud Secrets manager](https://console.cloud.google.com/security/secret-manager), so that the virtual machine can access them.
Add the secrets for your specific project
in [Google Cloud Secrets manager](https://console.cloud.google.com/security/secret-manager), so that the virtual machine
can access them.
For Compass, the name of the secrets are in [secrets.ts](../../common/src/secrets.ts).
@@ -111,13 +132,16 @@ In root directory, run the local api with hot reload, along with all the other b
### Deploy
Run in this directory to deploy your code to the server.
```bash
./deploy-api.sh prod
```
### Connect to the server
Run in this directory to connect to the API server running as virtual machine in Google Cloud. You can access logs, files, debug, etc.
Run in this directory to connect to the API server running as virtual machine in Google Cloud. You can access logs,
files, debug, etc.
```bash
./ssh-api.sh prod
```
@@ -128,4 +152,11 @@ Useful commands once inside the server:
sudo journalctl -u konlet-startup --no-pager -efb
sudo docker logs -f $(sudo docker ps -alq)
docker exec -it $(sudo docker ps -alq) sh
docker run -it --rm $(docker images -q | head -n 1) sh
docker rmi -f $(docker images -aq)
```
### Documentation
The API docs are available at https://api.compassmeet.com. They are defined in [openapi.json](openapi.json).
Just a few endpoints are mentioned in that JSON doc. Feel free to help by adding the remaining ones!

View File

@@ -3,7 +3,7 @@ module.exports = {
{
name: "api",
script: "node",
args: "-r tsconfig-paths/register --dns-result-order=ipv4first backend/api/lib/serve.js",
args: "--dns-result-order=ipv4first backend/api/lib/serve.js",
env: {
NODE_ENV: "production",
NODE_PATH: "/usr/src/app/node_modules", // <- ensures Node finds tsconfig-paths

View File

@@ -175,7 +175,7 @@ resource "google_compute_backend_service" "api_backend" {
# URL map
resource "google_compute_url_map" "api_url_map" {
name = "${local.service_name}-url-map"
default_service = google_compute_backend_service.api_backend.id
default_service = google_compute_backend_service.api_backend.self_link
host_rule {
hosts = ["*"]
@@ -185,9 +185,33 @@ 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
}
}
}
# HTTPS proxy
resource "google_compute_target_https_proxy" "api_https_proxy" {
name = "${local.service_name}-https-proxy"

29
backend/api/openapi.json Normal file
View File

@@ -0,0 +1,29 @@
{
"openapi": "3.0.0",
"info": {
"title": "Compass API",
"version": "1.0.0"
},
"paths": {
"/health": {
"get": {
"summary": "Health",
"responses": {
"200": {
"description": "OK"
}
}
}
},
"/get-profiles": {
"get": {
"summary": "List profiles",
"responses": {
"200": {
"description": "OK"
}
}
}
}
}
}

View File

@@ -13,7 +13,7 @@
"debug": "nodemon -r tsconfig-paths/register --watch src -e ts --watch ../../common/src --watch ../shared/src --exec \"yarn build && node --inspect-brk src/serve.ts\"",
"dist": "yarn dist:clean && yarn dist:copy",
"dist:clean": "rm -rf dist && mkdir -p dist/common/lib dist/backend/shared/lib dist/backend/api/lib dist/backend/email/lib",
"dist:copy": "rsync -a --delete ../../common/lib/ dist/common/lib && rsync -a --delete ../shared/lib/ dist/backend/shared/lib && rsync -a --delete ../email/lib/ dist/backend/email/lib && rsync -a --delete ./lib/* dist/backend/api/lib && cp ../../yarn.lock dist && cp package.json dist",
"dist:copy": "rsync -a --delete ../../common/lib/ dist/common/lib && rsync -a --delete ../shared/lib/ dist/backend/shared/lib && rsync -a --delete ../email/lib/ dist/backend/email/lib && rsync -a --delete ./lib/* dist/backend/api/lib && cp ../../yarn.lock dist && cp package.json dist && cp openapi.json dist",
"watch": "tsc -w",
"verify": "yarn --cwd=../.. verify",
"verify:dir": "npx eslint . --max-warnings 0",
@@ -21,7 +21,7 @@
"regen-types-dev": "cd ../supabase && make ENV=dev regen-types"
},
"engines": {
"node": ">=16.0.0"
"node": ">=20.0.0"
},
"main": "src/serve.ts",
"dependencies": {
@@ -45,26 +45,28 @@
"cors": "2.8.5",
"dayjs": "1.11.4",
"express": "4.18.1",
"firebase-admin": "11.11.1",
"firebase-admin": "13.5.0",
"gcp-metadata": "6.1.0",
"jsonwebtoken": "9.0.0",
"lodash": "4.17.21",
"pg-promise": "11.4.1",
"posthog-node": "4.11.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-email": "3.0.7",
"resend": "4.1.2",
"string-similarity": "4.0.4",
"swagger-jsdoc": "6.2.8",
"swagger-ui-express": "5.0.1",
"tsconfig-paths": "4.2.0",
"twitter-api-v2": "1.15.0",
"ws": "8.17.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"zod": "3.21.4"
},
"devDependencies": {
"@types/cors": "2.8.17",
"@types/ws": "8.5.10",
"@types/react": "18.3.5",
"@types/react-dom": "18.3.0"
"@types/react-dom": "18.3.0",
"@types/swagger-ui-express": "4.1.8",
"@types/ws": "8.5.10"
}
}

View File

@@ -1,56 +1,58 @@
import { API, type APIPath } from 'common/api/schema'
import { APIError, pathWithPrefix } from 'common/api/utils'
import {API, type APIPath} from 'common/api/schema'
import {APIError, pathWithPrefix} from 'common/api/utils'
import cors from 'cors'
import * as crypto from 'crypto'
import express from 'express'
import { type ErrorRequestHandler, type RequestHandler } from 'express'
import { hrtime } from 'node:process'
import { withMonitoringContext } from 'shared/monitoring/context'
import { log } from 'shared/monitoring/log'
import { metrics } from 'shared/monitoring/metrics'
import { banUser } from './ban-user'
import { blockUser, unblockUser } from './block-user'
import { getCompatibleLoversHandler } from './compatible-lovers'
import { createComment } from './create-comment'
import { createCompatibilityQuestion } from './create-compatibility-question'
import { createLover } from './create-lover'
import { createUser } from './create-user'
import { getCompatibilityQuestions } from './get-compatibililty-questions'
import { getLikesAndShips } from './get-likes-and-ships'
import { getLoverAnswers } from './get-lover-answers'
import { getLovers } from './get-lovers'
import { getSupabaseToken } from './get-supabase-token'
import { getDisplayUser, getUser } from './get-user'
import { getMe } from './get-me'
import { hasFreeLike } from './has-free-like'
import { health } from './health'
import { typedEndpoint, type APIHandler } from './helpers/endpoint'
import { hideComment } from './hide-comment'
import { likeLover } from './like-lover'
import { markAllNotifsRead } from './mark-all-notifications-read'
import { removePinnedPhoto } from './remove-pinned-photo'
import { report } from './report'
import { searchLocation } from './search-location'
import { searchNearCity } from './search-near-city'
import { shipLovers } from './ship-lovers'
import { starLover } from './star-lover'
import { updateLover } from './update-lover'
import { updateMe } from './update-me'
import { deleteMe } from './delete-me'
import { getCurrentPrivateUser } from './get-current-private-user'
import { createPrivateUserMessage } from './create-private-user-message'
import express, {type ErrorRequestHandler, type RequestHandler} from 'express'
import {hrtime} from 'node:process'
import {withMonitoringContext} from 'shared/monitoring/context'
import {log} from 'shared/monitoring/log'
import {metrics} from 'shared/monitoring/metrics'
import {banUser} from './ban-user'
import {blockUser, unblockUser} from './block-user'
import {getCompatibleProfilesHandler} from './compatible-profiles'
import {createComment} from './create-comment'
import {createCompatibilityQuestion} from './create-compatibility-question'
import {createProfile} from './create-profile'
import {createUser} from './create-user'
import {getCompatibilityQuestions} from './get-compatibililty-questions'
import {getLikesAndShips} from './get-likes-and-ships'
import {getProfileAnswers} from './get-profile-answers'
import {getProfiles} from './get-profiles'
import {getSupabaseToken} from './get-supabase-token'
import {getDisplayUser, getUser} from './get-user'
import {getMe} from './get-me'
import {hasFreeLike} from './has-free-like'
import {health} from './health'
import {type APIHandler, typedEndpoint} from './helpers/endpoint'
import {hideComment} from './hide-comment'
import {likeProfile} from './like-profile'
import {markAllNotifsRead} from './mark-all-notifications-read'
import {removePinnedPhoto} from './remove-pinned-photo'
import {report} from './report'
import {searchLocation} from './search-location'
import {searchNearCity} from './search-near-city'
import {shipProfiles} from './ship-profiles'
import {starProfile} from './star-profile'
import {updateProfile} from './update-profile'
import {updateMe} from './update-me'
import {deleteMe} from './delete-me'
import {getCurrentPrivateUser} from './get-current-private-user'
import {createPrivateUserMessage} from './create-private-user-message'
import {
getChannelMemberships,
getChannelMessages,
getLastSeenChannelTime,
setChannelLastSeenTime,
} from 'api/get-private-messages'
import { searchUsers } from './search-users'
import { createPrivateUserMessageChannel } from './create-private-user-message-channel'
import { leavePrivateUserMessageChannel } from './leave-private-user-message-channel'
import { updatePrivateUserMessageChannel } from './update-private-user-message-channel'
import { getNotifications } from './get-notifications'
import { updateNotifSettings } from './update-notif-setting'
import {searchUsers} from './search-users'
import {createPrivateUserMessageChannel} from './create-private-user-message-channel'
import {leavePrivateUserMessageChannel} from './leave-private-user-message-channel'
import {updatePrivateUserMessageChannel} from './update-private-user-message-channel'
import {getNotifications} from './get-notifications'
import {updateNotifSettings} from './update-notif-setting'
import swaggerUi from "swagger-ui-express"
import * as fs from "fs"
import {sendSearchNotifications} from "api/send-search-notifications";
const allowCorsUnrestricted: RequestHandler = cors({})
@@ -66,15 +68,15 @@ const requestMonitoring: RequestHandler = (req, _res, next) => {
const traceId = traceContext
? traceContext.split('/')[0]
: crypto.randomUUID()
const context = { endpoint: req.path, traceId }
const context = {endpoint: req.path, traceId}
withMonitoringContext(context, () => {
const startTs = hrtime.bigint()
log(`${req.method} ${req.url}`)
metrics.inc('http/request_count', { endpoint: req.path })
metrics.inc('http/request_count', {endpoint: req.path})
next()
const endTs = hrtime.bigint()
const latencyMs = Number(endTs - startTs) / 1e6
metrics.push('http/request_latency', latencyMs, { endpoint: req.path })
metrics.push('http/request_latency', latencyMs, {endpoint: req.path})
})
}
@@ -82,7 +84,7 @@ const apiErrorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
if (error instanceof APIError) {
log.info(error)
if (!res.headersSent) {
const output: { [k: string]: unknown } = { message: error.message }
const output: { [k: string]: unknown } = {message: error.message}
if (error.details != null) {
output.details = error.details
}
@@ -91,7 +93,7 @@ const apiErrorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
} else {
log.error(error)
if (!res.headersSent) {
res.status(500).json({ message: error.stack, error })
res.status(500).json({message: error.stack, error})
}
}
}
@@ -99,6 +101,22 @@ const apiErrorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
export const app = express()
app.use(requestMonitoring)
const swaggerDocument = JSON.parse(fs.readFileSync("./openapi.json", "utf-8"))
swaggerDocument.info = {
...swaggerDocument.info,
description: "Compass is a free, open-source platform to help people form deep, meaningful, and lasting connections — whether platonic, romantic, or collaborative. Its made possible by contributions from the community, including code, ideas, feedback, and donations. Unlike typical apps, Compass prioritizes values, interests, and personality over swipes and ads, giving you full control over who you discover and how you connect.",
version: "1.0.0",
contact: {
name: "Compass",
email: "compass.meet.info@gmail.com",
url: "https://compassmeet.com"
}
};
const rootPath = pathWithPrefix("/")
app.get(rootPath, swaggerUi.setup(swaggerDocument))
app.use(rootPath, swaggerUi.serve)
app.options('*', allowCorsUnrestricted)
const handlers: { [k in APIPath]: APIHandler<k> } = {
@@ -116,26 +134,26 @@ const handlers: { [k in APIPath]: APIHandler<k> } = {
'ban-user': banUser,
report: report,
'create-user': createUser,
'create-lover': createLover,
'create-profile': createProfile,
me: getMe,
'me/private': getCurrentPrivateUser,
'me/update': updateMe,
'update-notif-settings': updateNotifSettings,
'me/delete': deleteMe,
'update-lover': updateLover,
'like-lover': likeLover,
'ship-lovers': shipLovers,
'update-profile': updateProfile,
'like-profile': likeProfile,
'ship-profiles': shipProfiles,
'get-likes-and-ships': getLikesAndShips,
'has-free-like': hasFreeLike,
'star-lover': starLover,
'get-lovers': getLovers,
'get-lover-answers': getLoverAnswers,
'star-profile': starProfile,
'get-profiles': getProfiles,
'get-profile-answers': getProfileAnswers,
'get-compatibility-questions': getCompatibilityQuestions,
'remove-pinned-photo': removePinnedPhoto,
'create-comment': createComment,
'hide-comment': hideComment,
'create-compatibility-question': createCompatibilityQuestion,
'compatible-lovers': getCompatibleLoversHandler,
'compatible-profiles': getCompatibleProfilesHandler,
'search-location': searchLocation,
'search-near-city': searchNearCity,
'create-private-user-message': createPrivateUserMessage,
@@ -151,7 +169,7 @@ const handlers: { [k in APIPath]: APIHandler<k> } = {
Object.entries(handlers).forEach(([path, handler]) => {
const api = API[path as APIPath]
const cache = cacheController((api as any).cache)
const url = '/' + pathWithPrefix(path as APIPath)
const url = pathWithPrefix('/' + path as APIPath)
const apiRoute = [
url,
@@ -173,6 +191,27 @@ Object.entries(handlers).forEach(([path, handler]) => {
}
})
// console.log('COMPASS_API_KEY:', process.env.COMPASS_API_KEY)
// Internal Endpoints
app.post(pathWithPrefix("/internal/send-search-notifications"),
async (req, res) => {
const apiKey = req.header("x-api-key");
if (apiKey !== process.env.COMPASS_API_KEY) {
return res.status(401).json({error: "Unauthorized"});
}
try {
const result = await sendSearchNotifications()
return res.status(200).json(result)
} catch (err) {
console.error("Failed to send notifications:", err);
return res.status(500).json({error: "Internal server error"});
}
}
);
app.use(allowCorsUnrestricted, (req, res) => {
if (req.method === 'OPTIONS') {
res.status(200).send()
@@ -181,7 +220,7 @@ app.use(allowCorsUnrestricted, (req, res) => {
.status(404)
.set('Content-Type', 'application/json')
.json({
message: `The requested route '${req.path}' does not exist. Please check your URL for any misspellings or refer to app.ts`,
message: `This is the Compass API, but the requested route '${req.path}' does not exist. Please check your URL for any misspellings, the docs at https://api.compassmeet.com, or simply refer to app.ts on GitHub`,
})
}
})

View File

@@ -1,61 +0,0 @@
import { groupBy, sortBy } from 'lodash'
import { APIError, type APIHandler } from 'api/helpers/endpoint'
import { getCompatibilityScore } from 'common/love/compatibility-score'
import {
getLover,
getCompatibilityAnswers,
getGenderCompatibleLovers,
} from 'shared/love/supabase'
import { log } from 'shared/utils'
export const getCompatibleLoversHandler: APIHandler<
'compatible-lovers'
> = async (props) => {
return getCompatibleLovers(props.userId)
}
export const getCompatibleLovers = async (userId: string) => {
const lover = await getLover(userId)
log('got lover', {
id: lover?.id,
userId: lover?.user_id,
username: lover?.user?.username,
})
if (!lover) throw new APIError(404, 'Lover not found')
const lovers = await getGenderCompatibleLovers(lover)
const loverAnswers = await getCompatibilityAnswers([
userId,
...lovers.map((l) => l.user_id),
])
log('got lover answers ' + loverAnswers.length)
const answersByUserId = groupBy(loverAnswers, 'creator_id')
const loverCompatibilityScores = Object.fromEntries(
lovers.map(
(l) =>
[
l.user_id,
getCompatibilityScore(
answersByUserId[lover.user_id] ?? [],
answersByUserId[l.user_id] ?? []
),
] as const
)
)
const sortedCompatibleLovers = sortBy(
lovers,
(l) => loverCompatibilityScores[l.user_id].score
).reverse()
return {
status: 'success',
lover,
compatibleLovers: sortedCompatibleLovers,
loverCompatibilityScores,
}
}

View File

@@ -0,0 +1,61 @@
import { groupBy, sortBy } from 'lodash'
import { APIError, type APIHandler } from 'api/helpers/endpoint'
import { getCompatibilityScore } from 'common/love/compatibility-score'
import {
getProfile,
getCompatibilityAnswers,
getGenderCompatibleProfiles,
} from 'shared/love/supabase'
import { log } from 'shared/utils'
export const getCompatibleProfilesHandler: APIHandler<
'compatible-profiles'
> = async (props) => {
return getCompatibleProfiles(props.userId)
}
export const getCompatibleProfiles = async (userId: string) => {
const profile = await getProfile(userId)
log('got profile', {
id: profile?.id,
userId: profile?.user_id,
username: profile?.user?.username,
})
if (!profile) throw new APIError(404, 'Profile not found')
const profiles = await getGenderCompatibleProfiles(profile)
const profileAnswers = await getCompatibilityAnswers([
userId,
...profiles.map((l) => l.user_id),
])
log('got profile answers ' + profileAnswers.length)
const answersByUserId = groupBy(profileAnswers, 'creator_id')
const profileCompatibilityScores = Object.fromEntries(
profiles.map(
(l) =>
[
l.user_id,
getCompatibilityScore(
answersByUserId[profile.user_id] ?? [],
answersByUserId[l.user_id] ?? []
),
] as const
)
)
const sortedCompatibleProfiles = sortBy(
profiles,
(l) => profileCompatibilityScores[l.user_id].score
).reverse()
return {
status: 'success',
profile,
compatibleProfiles: sortedCompatibleProfiles,
profileCompatibilityScores,
}
}

View File

@@ -32,8 +32,8 @@ export const createComment: APIHandler<'create-comment'> = async (
if (!onUser) throw new APIError(404, 'User not found')
const pg = createSupabaseDirectClient()
const comment = await pg.one<Row<'lover_comments'>>(
`insert into lover_comments (user_id, user_name, user_username, user_avatar_url, on_user_id, content, reply_to_comment_id)
const comment = await pg.one<Row<'profile_comments'>>(
`insert into profile_comments (user_id, user_name, user_username, user_avatar_url, on_user_id, content, reply_to_comment_id)
values ($1, $2, $3, $4, $5, $6, $7) returning *`,
[
creator.id,
@@ -46,7 +46,7 @@ export const createComment: APIHandler<'create-comment'> = async (
]
)
if (onUser.id !== creator.id)
await createNewCommentOnLoverNotification(
await createNewCommentOnProfileNotification(
onUser,
creator,
richTextToString(content),
@@ -84,7 +84,7 @@ const validateComment = async (
return { content, creator }
}
const createNewCommentOnLoverNotification = async (
const createNewCommentOnProfileNotification = async (
onUser: User,
creator: User,
sourceText: string,
@@ -104,7 +104,7 @@ const createNewCommentOnLoverNotification = async (
createdTime: Date.now(),
isSeen: false,
sourceId: commentId.toString(),
sourceType: 'comment_on_lover',
sourceType: 'comment_on_profile',
sourceUpdateType: 'created',
sourceUserName: creator.name,
sourceUserUsername: creator.username,

View File

@@ -8,11 +8,11 @@ import { updateUser } from 'shared/supabase/users'
import { tryCatch } from 'common/util/try-catch'
import { insert } from 'shared/supabase/utils'
export const createLover: APIHandler<'create-lover'> = async (body, auth) => {
export const createProfile: APIHandler<'create-profile'> = async (body, auth) => {
const pg = createSupabaseDirectClient()
const { data: existingUser } = await tryCatch(
pg.oneOrNone<{ id: string }>('select id from lovers where user_id = $1', [
pg.oneOrNone<{ id: string }>('select id from profiles where user_id = $1', [
auth.uid,
])
)
@@ -31,7 +31,7 @@ export const createLover: APIHandler<'create-lover'> = async (body, auth) => {
console.log('body', body)
const { data, error } = await tryCatch(
insert(pg, 'lovers', { user_id: auth.uid, ...body })
insert(pg, 'profiles', { user_id: auth.uid, ...body })
)
if (error) {
@@ -40,7 +40,7 @@ export const createLover: APIHandler<'create-lover'> = async (body, auth) => {
}
log('Created user', data)
await track(user.id, 'create lover', { username: user.username })
await track(user.id, 'create profile', { username: user.username })
return data
}

View File

@@ -21,7 +21,7 @@ export const createUser: APIHandler<'create-user'> = async (
auth,
req
) => {
const { deviceToken: preDeviceToken, adminToken } = props
const { deviceToken: preDeviceToken } = props
const firebaseUser = await admin.auth().getUser(auth.uid)
const testUserAKAEmailPasswordUser =
@@ -123,7 +123,7 @@ export const createUser: APIHandler<'create-user'> = async (
log('created user ', { username: user.username, firebaseId: auth.uid })
const continuation = async () => {
await track(auth.uid, 'create lover', { username: user.username })
await track(auth.uid, 'create profile', { username: user.username })
}
return {

View File

@@ -22,11 +22,11 @@ export const getLikesAndShipsMain = async (userId: string) => {
`
select target_id, love_likes.created_time
from love_likes
join lovers on lovers.user_id = love_likes.target_id
join profiles on profiles.user_id = love_likes.target_id
join users on users.id = love_likes.target_id
where creator_id = $1
and looking_for_matches
and lovers.pinned_url is not null
and profiles.pinned_url is not null
and (data->>'isBannedFromPosting' != 'true' or data->>'isBannedFromPosting' is null)
order by created_time desc
`,
@@ -44,11 +44,11 @@ export const getLikesAndShipsMain = async (userId: string) => {
`
select creator_id, love_likes.created_time
from love_likes
join lovers on lovers.user_id = love_likes.creator_id
join profiles on profiles.user_id = love_likes.creator_id
join users on users.id = love_likes.creator_id
where target_id = $1
and looking_for_matches
and lovers.pinned_url is not null
and profiles.pinned_url is not null
and (data->>'isBannedFromPosting' != 'true' or data->>'isBannedFromPosting' is null)
order by created_time desc
`,
@@ -71,11 +71,11 @@ export const getLikesAndShipsMain = async (userId: string) => {
target1_id, target2_id, creator_id, love_ships.created_time,
target1_id as target_id
from love_ships
join lovers on lovers.user_id = love_ships.target1_id
join profiles on profiles.user_id = love_ships.target1_id
join users on users.id = love_ships.target1_id
where target2_id = $1
and lovers.looking_for_matches
and lovers.pinned_url is not null
and profiles.looking_for_matches
and profiles.pinned_url is not null
and (users.data->>'isBannedFromPosting' != 'true' or users.data->>'isBannedFromPosting' is null)
union all
@@ -84,11 +84,11 @@ export const getLikesAndShipsMain = async (userId: string) => {
target1_id, target2_id, creator_id, love_ships.created_time,
target2_id as target_id
from love_ships
join lovers on lovers.user_id = love_ships.target2_id
join profiles on profiles.user_id = love_ships.target2_id
join users on users.id = love_ships.target2_id
where target1_id = $1
and lovers.looking_for_matches
and lovers.pinned_url is not null
and profiles.looking_for_matches
and profiles.pinned_url is not null
and (users.data->>'isBannedFromPosting' != 'true' or users.data->>'isBannedFromPosting' is null)
`,
[userId],

View File

@@ -1,134 +0,0 @@
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 { getCompatibleLovers } from 'api/compatible-lovers'
import { intersection } from 'lodash'
export const getLovers: APIHandler<'get-lovers'> = async (props, _auth) => {
const pg = createSupabaseDirectClient()
const {
limit: limitParam,
after,
name,
genders,
pref_gender,
pref_age_min,
pref_age_max,
pref_relation_styles,
wants_kids_strength,
has_kids,
is_smoker,
geodbCityIds,
compatibleWithUserId,
orderBy: orderByParam,
} = props
// compatibility. TODO: do this in sql
if (orderByParam === 'compatibility_score') {
if (!compatibleWithUserId) return { status: 'fail', lovers: [] }
const { compatibleLovers } = await getCompatibleLovers(compatibleWithUserId)
const lovers = compatibleLovers.filter(
(l) =>
(!name || l.user.name.toLowerCase().includes(name.toLowerCase())) &&
(!genders || genders.includes(l.gender)) &&
(!pref_gender || intersection(pref_gender, l.pref_gender).length) &&
(!pref_age_min || l.age >= pref_age_min) &&
(!pref_age_max || l.age <= pref_age_max) &&
(!pref_relation_styles ||
intersection(pref_relation_styles, l.pref_relation_styles).length) &&
(!wants_kids_strength ||
wants_kids_strength == -1 ||
(wants_kids_strength >= 2
? l.wants_kids_strength >= wants_kids_strength
: l.wants_kids_strength <= wants_kids_strength)) &&
(has_kids == undefined ||
has_kids == -1 ||
(has_kids == 0 && !l.has_kids) ||
(l.has_kids && l.has_kids > 0)) &&
(!is_smoker || l.is_smoker === is_smoker) &&
(!geodbCityIds ||
(l.geodb_city_id && geodbCityIds.includes(l.geodb_city_id)))
)
const cursor = after
? lovers.findIndex((l) => l.id.toString() === after) + 1
: 0
console.log(cursor)
return {
status: 'success',
lovers: lovers.slice(cursor, cursor + limitParam),
}
}
const query = renderSql(
select('lovers.*, name, username, users.data as user'),
from('lovers'),
join('users on users.id = lovers.user_id'),
where('looking_for_matches = true'),
// where(`pinned_url is not null and pinned_url != ''`),
where(
`(data->>'isBannedFromPosting' != 'true' or data->>'isBannedFromPosting' is null)`
),
where(`data->>'userDeleted' != 'true' or data->>'userDeleted' is null`),
name &&
where(`lower(users.name) ilike '%' || lower($(name)) || '%' or lower(bio::text) ilike '%' || lower($(name)) || '%'`, { name }),
genders?.length && where(`gender = ANY($(gender))`, { gender: genders }),
pref_gender?.length &&
where(`pref_gender && $(pref_gender)`, { pref_gender }),
pref_age_min !== undefined &&
where(`age >= $(pref_age_min)`, { pref_age_min }),
pref_age_max !== undefined &&
where(`age <= $(pref_age_max)`, { pref_age_max }),
pref_relation_styles?.length &&
where(`pref_relation_styles && $(pref_relation_styles)`, {
pref_relation_styles,
}),
wants_kids_strength !== undefined &&
wants_kids_strength !== -1 &&
where(
wants_kids_strength >= 2
? `wants_kids_strength >= $(wants_kids_strength)`
: `wants_kids_strength <= $(wants_kids_strength)`,
{ wants_kids_strength }
),
has_kids === 0 && where(`has_kids IS NULL OR has_kids = 0`),
has_kids && has_kids > 0 && where(`has_kids > 0`),
is_smoker !== undefined && where(`is_smoker = $(is_smoker)`, { is_smoker }),
geodbCityIds?.length &&
where(`geodb_city_id = ANY($(geodbCityIds))`, { geodbCityIds }),
orderBy(`${orderByParam} desc`),
after &&
where(
`lovers.${orderByParam} < (select lovers.${orderByParam} from lovers where id = $(after))`,
{ after }
),
limit(limitParam)
)
const lovers = await pg.map(query, [], convertRow)
return { status: 'success', lovers: lovers }
}

View File

@@ -2,7 +2,7 @@ import { type APIHandler } from 'api/helpers/endpoint'
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { Row } from 'common/supabase/utils'
export const getLoverAnswers: APIHandler<'get-lover-answers'> = async (
export const getProfileAnswers: APIHandler<'get-profile-answers'> = async (
props,
_auth
) => {

View File

@@ -0,0 +1,173 @@
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 {getCompatibleProfiles} from 'api/compatible-profiles'
import {intersection} from 'lodash'
import {MAX_INT, MIN_INT} from "common/constants";
export type profileQueryType = {
limit?: number | undefined,
after?: string | undefined,
// Search and filter parameters
name?: string | undefined,
genders?: String[] | undefined,
pref_gender?: String[] | undefined,
pref_age_min?: number | undefined,
pref_age_max?: number | undefined,
pref_relation_styles?: String[] | undefined,
wants_kids_strength?: number | undefined,
has_kids?: number | undefined,
is_smoker?: boolean | undefined,
geodbCityIds?: String[] | undefined,
compatibleWithUserId?: string | undefined,
skipId?: string | undefined,
orderBy?: string | undefined,
lastModificationWithin?: string | undefined,
}
export const loadProfiles = async (props: profileQueryType) => {
const pg = createSupabaseDirectClient()
console.log(props)
const {
limit: limitParam,
after,
name,
genders,
pref_gender,
pref_age_min,
pref_age_max,
pref_relation_styles,
wants_kids_strength,
has_kids,
is_smoker,
geodbCityIds,
compatibleWithUserId,
orderBy: orderByParam = 'created_time',
lastModificationWithin,
skipId,
} = props
const keywords = name ? name.split(",").map(q => q.trim()).filter(Boolean) : []
// console.debug('keywords:', keywords)
// compatibility. TODO: do this in sql
if (orderByParam === 'compatibility_score') {
if (!compatibleWithUserId) {
console.error('Incompatible with user ID')
throw Error('Incompatible with user ID')
}
const {compatibleProfiles} = await getCompatibleProfiles(compatibleWithUserId)
const profiles = compatibleProfiles.filter(
(l) =>
(!name || l.user.name.toLowerCase().includes(name.toLowerCase())) &&
(!genders || genders.includes(l.gender)) &&
(!pref_gender || intersection(pref_gender, l.pref_gender).length) &&
(!pref_age_min || (l.age ?? MAX_INT) >= pref_age_min) &&
(!pref_age_max || (l.age ?? MIN_INT) <= pref_age_max) &&
(!pref_relation_styles ||
intersection(pref_relation_styles, l.pref_relation_styles).length) &&
(!wants_kids_strength ||
wants_kids_strength == -1 ||
(wants_kids_strength >= 2
? l.wants_kids_strength >= wants_kids_strength
: l.wants_kids_strength <= wants_kids_strength)) &&
(has_kids == undefined ||
has_kids == -1 ||
(has_kids == 0 && !l.has_kids) ||
(l.has_kids && l.has_kids > 0)) &&
(!is_smoker || l.is_smoker === is_smoker) &&
(l.id.toString() != skipId) &&
(!geodbCityIds ||
(l.geodb_city_id && geodbCityIds.includes(l.geodb_city_id)))
)
const cursor = after
? profiles.findIndex((l) => l.id.toString() === after) + 1
: 0
console.log(cursor)
if (limitParam) return profiles.slice(cursor, cursor + limitParam)
return profiles
}
const query = renderSql(
select('profiles.*, name, username, users.data as user'),
from('profiles'),
join('users on users.id = profiles.user_id'),
where('looking_for_matches = true'),
// where(`pinned_url is not null and pinned_url != ''`),
where(
`(data->>'isBannedFromPosting' != 'true' or data->>'isBannedFromPosting' is null)`
),
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)) || '%'`,
{word}
)),
genders?.length && where(`gender = ANY($(gender))`, {gender: genders}),
pref_gender?.length &&
where(`pref_gender && $(pref_gender)`, {pref_gender}),
pref_age_min &&
where(`age >= $(pref_age_min) or age is null`, {pref_age_min}),
pref_age_max &&
where(`age <= $(pref_age_max) or age is null`, {pref_age_max}),
pref_relation_styles?.length &&
where(
`pref_relation_styles IS NULL OR pref_relation_styles = '{}' OR pref_relation_styles && $(pref_relation_styles)`,
{ pref_relation_styles }
),
!!wants_kids_strength &&
wants_kids_strength !== -1 &&
where(
wants_kids_strength >= 2
? `wants_kids_strength >= $(wants_kids_strength)`
: `wants_kids_strength <= $(wants_kids_strength)`,
{wants_kids_strength}
),
has_kids === 0 && where(`has_kids IS NULL OR has_kids = 0`),
has_kids && has_kids > 0 && where(`has_kids > 0`),
is_smoker !== undefined && where(`is_smoker = $(is_smoker)`, {is_smoker}),
geodbCityIds?.length &&
where(`geodb_city_id = ANY($(geodbCityIds))`, {geodbCityIds}),
skipId && where(`user_id != $(skipId)`, {skipId}),
orderBy(`${orderByParam} desc`),
after &&
where(
`profiles.${orderByParam} < (select profiles.${orderByParam} from profiles where id = $(after))`,
{after}
),
lastModificationWithin && where(`last_modification_time >= NOW() - INTERVAL $(lastModificationWithin)`, {lastModificationWithin}),
limitParam && limit(limitParam)
)
// console.log('query:', query)
return await pg.map(query, [], convertRow)
}
export const getProfiles: APIHandler<'get-profiles'> = async (props, _auth) => {
try {
const profiles = await loadProfiles(props)
return {status: 'success', profiles: profiles}
} catch {
return {status: 'fail', profiles: []}
}
}

View File

@@ -10,8 +10,8 @@ export const hideComment: APIHandler<'hide-comment'> = async (
auth
) => {
const pg = createSupabaseDirectClient()
const comment = await pg.oneOrNone<Row<'lover_comments'>>(
`select * from lover_comments where id = $1`,
const comment = await pg.oneOrNone<Row<'profile_comments'>>(
`select * from profile_comments where id = $1`,
[commentId]
)
if (!comment) {
@@ -26,7 +26,7 @@ export const hideComment: APIHandler<'hide-comment'> = async (
throw new APIError(403, 'You are not allowed to hide this comment')
}
await pg.none(`update lover_comments set hidden = $2 where id = $1`, [
await pg.none(`update profile_comments set hidden = $2 where id = $1`, [
commentId,
hide,
])

View File

@@ -6,7 +6,7 @@ import { log } from 'shared/utils'
import { tryCatch } from 'common/util/try-catch'
import { Row } from 'common/supabase/utils'
export const likeLover: APIHandler<'like-lover'> = async (props, auth) => {
export const likeProfile: APIHandler<'like-profile'> = async (props, auth) => {
const { targetUserId, remove } = props
const creatorId = auth.uid

View File

@@ -17,7 +17,7 @@ export const removePinnedPhoto: APIHandler<'remove-pinned-photo'> = async (
const pg = createSupabaseDirectClient()
const { error } = await tryCatch(
pg.none('update lovers set pinned_url = null where user_id = $1', [userId])
pg.none('update profiles set pinned_url = null where user_id = $1', [userId])
)
if (error) {

View File

@@ -0,0 +1,81 @@
import {createSupabaseDirectClient} from "shared/supabase/init";
import {from, renderSql, select} from "shared/supabase/sql-builder";
import {loadProfiles, profileQueryType} from "api/get-profiles";
import {Row} from "common/supabase/utils";
import {sendSearchAlertsEmail} from "email/functions/helpers";
import {MatchesByUserType} from "common/love/bookmarked_searches";
import {keyBy} from "lodash";
export function convertSearchRow(row: any): any {
return row
}
export const notifyBookmarkedSearch = async (matches: MatchesByUserType) => {
for (const [_, value] of Object.entries(matches)) {
await sendSearchAlertsEmail(value.user, value.privateUser, value.matches)
}
}
export const sendSearchNotifications = async () => {
const pg = createSupabaseDirectClient()
const search_query = renderSql(
select('bookmarked_searches.*'),
from('bookmarked_searches'),
)
const searches = await pg.map(search_query, [], convertSearchRow) as Row<'bookmarked_searches'>[]
console.log(`Running ${searches.length} bookmarked searches`)
const _users = await pg.map(
renderSql(
select('users.*'),
from('users'),
),
[],
convertSearchRow
) as Row<'users'>[]
const users = keyBy(_users, 'id')
console.log('users', users)
const _privateUsers = await pg.map(
renderSql(
select('private_users.*'),
from('private_users'),
),
[],
convertSearchRow
) as Row<'private_users'>[]
const privateUsers = keyBy(_privateUsers, 'id')
console.log('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 profiles = await loadProfiles(props as profileQueryType)
console.log(profiles.map((item: any) => item.name))
if (!profiles.length) continue
if (!(row.creator_id in matches)) {
if (!privateUsers[row.creator_id]) continue
matches[row.creator_id] = {
user: users[row.creator_id],
privateUser: privateUsers[row.creator_id]['data'],
matches: [],
}
}
matches[row.creator_id].matches.push({
id: row.creator_id,
description: {filters: row.search_filters, location: row.location},
matches: profiles.map((item: any) => ({
name: item.name,
username: item.username,
})),
})
}
console.log('matches:', JSON.stringify(matches, null, 2))
await notifyBookmarkedSearch(matches)
return {status: 'success'}
}

View File

@@ -1,9 +1,10 @@
import * as admin from 'firebase-admin'
import { getLocalEnv, initAdmin } from 'shared/init-admin'
import { loadSecretsToEnv, getServiceAccountCredentials } from 'common/secrets'
import { LOCAL_DEV, log } from 'shared/utils'
import { METRIC_WRITER } from 'shared/monitoring/metric-writer'
import { listen as webSocketListen } from 'shared/websockets/server'
import {getLocalEnv, initAdmin} from 'shared/init-admin'
import {loadSecretsToEnv, getServiceAccountCredentials} from 'common/secrets'
import {log} from 'shared/utils'
import {LOCAL_DEV} 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....')
@@ -19,12 +20,12 @@ if (LOCAL_DEV) {
METRIC_WRITER.start()
import { app } from './app'
import {app} from './app'
const credentials = LOCAL_DEV
? getServiceAccountCredentials(getLocalEnv())
: // No explicit credentials needed for deployed service.
undefined
undefined
const startupProcess = async () => {
await loadSecretsToEnv(credentials)

View File

@@ -5,7 +5,7 @@ import { log } from 'shared/utils'
import { tryCatch } from 'common/util/try-catch'
import { insert } from 'shared/supabase/utils'
export const shipLovers: APIHandler<'ship-lovers'> = async (props, auth) => {
export const shipProfiles: APIHandler<'ship-profiles'> = async (props, auth) => {
const { targetUserId1, targetUserId2, remove } = props
const creatorId = auth.uid

View File

@@ -5,7 +5,7 @@ import { tryCatch } from 'common/util/try-catch'
import { Row } from 'common/supabase/utils'
import { insert } from 'shared/supabase/utils'
export const starLover: APIHandler<'star-lover'> = async (props, auth) => {
export const starProfile: APIHandler<'star-profile'> = async (props, auth) => {
const { targetUserId, remove } = props
const creatorId = auth.uid

View File

@@ -7,25 +7,25 @@ import { tryCatch } from 'common/util/try-catch'
import { update } from 'shared/supabase/utils'
import { type Row } from 'common/supabase/utils'
export const updateLover: APIHandler<'update-lover'> = async (
export const updateProfile: APIHandler<'update-profile'> = async (
parsedBody,
auth
) => {
log('parsedBody', parsedBody)
const pg = createSupabaseDirectClient()
const { data: existingLover } = await tryCatch(
pg.oneOrNone<Row<'lovers'>>('select * from lovers where user_id = $1', [
const { data: existingProfile } = await tryCatch(
pg.oneOrNone<Row<'profiles'>>('select * from profiles where user_id = $1', [
auth.uid,
])
)
if (!existingLover) {
throw new APIError(404, 'Lover not found')
if (!existingProfile) {
throw new APIError(404, 'Profile not found')
}
!parsedBody.last_online_time &&
log('Updating lover', { userId: auth.uid, parsedBody })
log('Updating profile', { userId: auth.uid, parsedBody })
await removePinnedUrlFromPhotoUrls(parsedBody)
if (parsedBody.avatar_url) {
@@ -33,12 +33,12 @@ export const updateLover: APIHandler<'update-lover'> = async (
}
const { data, error } = await tryCatch(
update(pg, 'lovers', 'user_id', { user_id: auth.uid, ...parsedBody })
update(pg, 'profiles', 'user_id', { user_id: auth.uid, ...parsedBody })
)
if (error) {
log('Error updating lover', error)
throw new APIError(500, 'Error updating lover')
log('Error updating profile', error)
throw new APIError(500, 'Error updating profile')
}
return data

View File

@@ -9,24 +9,47 @@
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"target": "esnext",
"skipLibCheck": true,
"jsx": "react-jsx",
"paths": {
"common/*": ["../../common/src/*", "../../../common/lib/*"],
"shared/*": ["../shared/src/*", "../../shared/lib/*"],
"email/*": ["../email/emails/*", "../../email/lib/*"],
"api/*": ["./src/*"]
"common/*": [
"../../common/src/*",
"../../../common/lib/*"
],
"shared/*": [
"../shared/src/*",
"../../shared/lib/*"
],
"email/*": [
"../email/emails/*",
"../../email/lib/*"
],
"api/*": [
"./src/*"
]
}
},
"ts-node": {
"require": ["tsconfig-paths/register"]
"require": [
"tsconfig-paths/register"
]
},
"references": [
{ "path": "../../common" },
{ "path": "../shared" },
{ "path": "../email" }
{
"path": "../../common"
},
{
"path": "../shared"
},
{
"path": "../email"
}
],
"compileOnSave": true,
"include": ["src/**/*.ts"]
"include": [
"src/**/*.ts",
"openapi.json"
]
}

View File

@@ -7,17 +7,31 @@ A live preview right in your browser so you don't need to keep sending real emai
First, install the dependencies:
```sh
npm install
# or
yarn
yarn install
```
Then, run the development server:
```sh
npm run dev
# or
yarn dev
```
Open [localhost:3000](http://localhost:3000) with your browser to see the result.
Open [localhost:3001](http://localhost:3001) with your browser to see the result.
### Notes
Right now, I can't make the email server run without breaking the backend API and web, as they require different versions of react.
To run the email server, temporarily install the deps in this folder. They require react 19.
```bash
yarn add -D @react-email/preview-server react-email
```
When you are done, reinstall react 18.2 by running `yarn clean-install` at the root so that you can run the backend and web servers again.
## Useful commands
```bash
```

View File

@@ -1,42 +1,43 @@
import { PrivateUser, User } from 'common/user'
import { getNotificationDestinationsForUser } from 'common/user-notification-preferences'
import { sendEmail } from './send-email'
import { NewMatchEmail } from '../new-match'
import { NewMessageEmail } from '../new-message'
import { NewEndorsementEmail } from '../new-endorsement'
import { Test } from '../test'
import { getLover } from 'shared/love/supabase'
import {renderToStaticMarkup} from "react-dom/server";
import {PrivateUser, User} from 'common/user'
import {getNotificationDestinationsForUser} from 'common/user-notification-preferences'
import {sendEmail} from './send-email'
import {NewMessageEmail} from '../new-message'
import {NewEndorsementEmail} from '../new-endorsement'
import {Test} from '../test'
import {getProfile} from 'shared/love/supabase'
import { render } from "@react-email/render"
import {MatchesType} from "common/love/bookmarked_searches";
import NewSearchAlertsEmail from "email/new-search_alerts";
const from = 'Compass <no-reply@compassmeet.com>'
export const sendNewMatchEmail = async (
privateUser: PrivateUser,
matchedWithUser: User
) => {
const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
privateUser,
'new_match'
)
if (!privateUser.email || !sendToEmail) return
const lover = await getLover(privateUser.id)
if (!lover) return
return await sendEmail({
from,
subject: `You have a new match!`,
to: privateUser.email,
react: (
<NewMatchEmail
onUser={lover.user}
email={privateUser.email}
matchedWithUser={matchedWithUser}
matchedLover={lover}
unsubscribeUrl={unsubscribeUrl}
/>
),
})
}
// export const sendNewMatchEmail = async (
// privateUser: PrivateUser,
// matchedWithUser: User
// ) => {
// const {sendToEmail, unsubscribeUrl} = getNotificationDestinationsForUser(
// privateUser,
// 'new_match'
// )
// if (!privateUser.email || !sendToEmail) return
// const profile = await getProfile(privateUser.id)
// if (!profile) return
//
// return await sendEmail({
// from,
// subject: `You have a new match!`,
// to: privateUser.email,
// react: (
// <NewMatchEmail
// onUser={profile.user}
// email={privateUser.email}
// matchedWithUser={matchedWithUser}
// matchedProfile={profile}
// unsubscribeUrl={unsubscribeUrl}
// />
// ),
// })
// }
export const sendNewMessageEmail = async (
privateUser: PrivateUser,
@@ -44,27 +45,27 @@ export const sendNewMessageEmail = async (
toUser: User,
channelId: number
) => {
const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
const {sendToEmail, unsubscribeUrl} = getNotificationDestinationsForUser(
privateUser,
'new_message'
)
if (!privateUser.email || !sendToEmail) return
const lover = await getLover(fromUser.id)
const profile = await getProfile(fromUser.id)
if (!lover) {
if (!profile) {
console.error('Could not send email notification: User not found')
return
}
console.log({
return await sendEmail({
from,
subject: `${fromUser.name} sent you a message!`,
to: privateUser.email,
html: renderToStaticMarkup(
html: await render(
<NewMessageEmail
fromUser={fromUser}
fromUserLover={lover}
fromUserProfile={profile}
toUser={toUser}
channelId={channelId}
unsubscribeUrl={unsubscribeUrl}
@@ -72,19 +73,30 @@ export const sendNewMessageEmail = async (
/>
),
})
}
export const sendSearchAlertsEmail = async (
toUser: User,
privateUser: PrivateUser,
matches: MatchesType[],
) => {
const {sendToEmail, unsubscribeUrl} = getNotificationDestinationsForUser(
privateUser,
'new_search_alerts'
)
const email = privateUser.email;
if (!email || !sendToEmail) return
return await sendEmail({
from,
subject: `${fromUser.name} sent you a message!`,
to: privateUser.email,
html: renderToStaticMarkup(
<NewMessageEmail
fromUser={fromUser}
fromUserLover={lover}
subject: `People aligned with your values just joined`,
to: email,
html: await render(
<NewSearchAlertsEmail
toUser={toUser}
channelId={channelId}
matches={matches}
unsubscribeUrl={unsubscribeUrl}
email={privateUser.email}
email={email}
/>
),
})
@@ -96,7 +108,7 @@ export const sendNewEndorsementEmail = async (
onUser: User,
text: string
) => {
const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
const {sendToEmail, unsubscribeUrl} = getNotificationDestinationsForUser(
privateUser,
'new_endorsement'
)
@@ -106,7 +118,7 @@ export const sendNewEndorsementEmail = async (
from,
subject: `${fromUser.name} just endorsed you!`,
to: privateUser.email,
react: (
html: await render(
<NewEndorsementEmail
fromUser={fromUser}
onUser={onUser}
@@ -123,6 +135,6 @@ export const sendTestEmail = async (toEmail: string) => {
from,
subject: 'Test email from Compass',
to: toEmail,
html: renderToStaticMarkup(<Test name="Test User" />),
html: await render(<Test name="Test User"/>),
})
}

View File

@@ -1,4 +1,4 @@
import { LoverRow } from 'common/love/lover'
import { ProfileRow } from 'common/love/profile'
import type { User } from 'common/user'
// for email template testing
@@ -27,11 +27,12 @@ export const sinclairUser: User = {
},
}
export const sinclairLover: LoverRow = {
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'],
@@ -124,11 +125,12 @@ export const jamesUser: User = {
},
}
export const jamesLover: LoverRow = {
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'],

View File

@@ -1,14 +1,14 @@
import {Body, Button, Container, Head, Html, Preview, Section, Text,} from '@react-email/components'
import {DOMAIN} from 'common/envs/constants'
import {type LoverRow} from 'common/love/lover'
import {type ProfileRow} from 'common/love/profile'
import {type User} from 'common/user'
import {jamesLover, jamesUser, sinclairUser} from './functions/mock'
import {jamesProfile, jamesUser, sinclairUser} from './functions/mock'
import {Footer} from "email/utils";
interface NewMatchEmailProps {
onUser: User
matchedWithUser: User
matchedLover: LoverRow
matchedProfile: ProfileRow
unsubscribeUrl: string
email?: string
}
@@ -16,12 +16,12 @@ interface NewMatchEmailProps {
export const NewMatchEmail = ({
onUser,
matchedWithUser,
matchedLover,
// matchedProfile,
unsubscribeUrl,
email
}: NewMatchEmailProps) => {
const name = onUser.name.split(' ')[0]
// const userImgSrc = getLoveOgImageUrl(matchedWithUser, matchedLover)
// const userImgSrc = getLoveOgImageUrl(matchedWithUser, matchedProfile)
const userUrl = `https://${DOMAIN}/${matchedWithUser.username}`
return (
@@ -72,7 +72,7 @@ export const NewMatchEmail = ({
NewMatchEmail.PreviewProps = {
onUser: sinclairUser,
matchedWithUser: jamesUser,
matchedLover: jamesLover,
matchedProfile: jamesProfile,
email: 'someone@gmail.com',
unsubscribeUrl: 'https://compassmeet.com/unsubscribe',
} as NewMatchEmailProps
@@ -88,11 +88,11 @@ const container = {
maxWidth: '600px',
}
const logoContainer = {
padding: '20px 0px 5px 0px',
textAlign: 'center' as const,
backgroundColor: '#ffffff',
}
// const logoContainer = {
// padding: '20px 0px 5px 0px',
// textAlign: 'center' as const,
// backgroundColor: '#ffffff',
// }
const content = {
backgroundColor: '#ffffff',
@@ -112,9 +112,9 @@ const imageContainer = {
margin: '20px 0',
}
const profileImage = {
// border: '1px solid #ec489a',
}
// const profileImage = {
// // border: '1px solid #ec489a',
// }
const button = {
backgroundColor: '#4887ec',

View File

@@ -1,30 +1,13 @@
import {
Body,
Button,
Container,
Head,
Html,
Img,
Link,
Preview,
Section,
Text,
} from '@react-email/components'
import { type User } from 'common/user'
import { type LoverRow } from 'common/love/lover'
import {
jamesLover,
jamesUser,
sinclairLover,
sinclairUser,
} from './functions/mock'
import { DOMAIN } from 'common/envs/constants'
import { getLoveOgImageUrl } from 'common/love/og-image'
import {button, container, content, Footer, imageContainer, main, paragraph, profileImage} from "email/utils";
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 {DOMAIN} from 'common/envs/constants'
import {button, container, content, Footer, imageContainer, main, paragraph} from "email/utils";
interface NewMessageEmailProps {
fromUser: User
fromUserLover: LoverRow
fromUserProfile: ProfileRow
toUser: User
channelId: number
unsubscribeUrl: string
@@ -32,21 +15,21 @@ interface NewMessageEmailProps {
}
export const NewMessageEmail = ({
fromUser,
fromUserLover,
toUser,
channelId,
unsubscribeUrl,
email,
}: NewMessageEmailProps) => {
fromUser,
// fromUserProfile,
toUser,
channelId,
unsubscribeUrl,
email,
}: NewMessageEmailProps) => {
const name = toUser.name.split(' ')[0]
const creatorName = fromUser.name
const messagesUrl = `https://${DOMAIN}/messages/${channelId}`
const userImgSrc = getLoveOgImageUrl(fromUser, fromUserLover)
// const userImgSrc = getLoveOgImageUrl(fromUser, fromUserProfile)
return (
<Html>
<Head />
<Head/>
<Preview>New message from {creatorName}</Preview>
<Body style={main}>
<Container style={container}>
@@ -90,7 +73,7 @@ export const NewMessageEmail = ({
NewMessageEmail.PreviewProps = {
fromUser: jamesUser,
fromUserLover: jamesLover,
fromUserProfile: jamesProfile,
toUser: sinclairUser,
channelId: 1,
email: 'someone@gmail.com',

View File

@@ -0,0 +1,150 @@
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 {DOMAIN} from 'common/envs/constants'
import {container, content, Footer, main, paragraph} from "email/utils";
import {MatchesType} from "common/love/bookmarked_searches";
import {formatFilters, locationType} from "common/searches"
import {FilterFields} from "common/filters";
interface NewMessageEmailProps {
toUser: User
matches: MatchesType[]
unsubscribeUrl: string
email?: string
}
export const NewSearchAlertsEmail = ({
toUser,
unsubscribeUrl,
matches,
email,
}: NewMessageEmailProps) => {
const name = toUser.name.split(' ')[0]
return (
<Html>
<Head/>
<Preview>New people share your values reach out and connect</Preview>
<Body style={main}>
<Container style={container}>
<Section style={content}>
<Text style={paragraph}>Hi {name},</Text>
<Text style={paragraph}>
In the past 24 hours, new people joined Compass whose values and
interests align with your saved searches. Compass is a gift from the
community, and it comes alive when people like you take the step to
connect with one another.
</Text>
{(matches || []).map((match) => (
<Section key={match.id} style={{marginBottom: "20px"}}>
<Text style={{fontWeight: "bold", marginBottom: "5px"}}>
{formatFilters(
match.description.filters as Partial<FilterFields>,
match.description.location as locationType
)?.join(" • ")}
</Text>
<Text style={{margin: 0}}>
{match.matches.map((p, i) => (
<span key={p.username}>
{p.name} (
<Link
href={`https://${DOMAIN}/${p.username}`}
style={{color: "#2563eb", textDecoration: "none"}}
>
@{p.username}
</Link>
)
{i < match.matches.length - 1 && ", "}
</span>
))}
</Text>
</Section>
))}
<Section style={{textAlign: "center", marginTop: "30px"}}>
<Text style={{marginBottom: "20px"}}>
If someone resonates with you, reach out. A simple hello can be the
start of a meaningful friendship, collaboration, or relationship.
</Text>
<Link
href={`https://${DOMAIN}/messages`}
style={{
display: "inline-block",
backgroundColor: "#2563eb",
color: "#ffffff",
padding: "12px 20px",
borderRadius: "6px",
textDecoration: "none",
fontWeight: "bold",
}}
>
Start a Conversation
</Link>
</Section>
<Text style={{marginTop: "40px", fontSize: "12px", color: "#555"}}>
Compass is built and sustained by the community no ads, no hidden
algorithms, no subscriptions. Your presence and participation make it
possible.
</Text>
</Section>
<Footer unsubscribeUrl={unsubscribeUrl} email={email ?? name}/>
</Container>
</Body>
</Html>
)
}
const matchSamples = [
{
"id": "ID search 1",
"description": {
"filters": {
"orderBy": "created_time"
},
"location": null
},
"matches": [
{
"name": "James Bond",
"username": "jamesbond"
},
{
"name": "Lily",
"username": "lilyrose"
}
]
},
{
"id": "ID search 2",
"description": {
"filters": {
"genders": [
"female"
],
"orderBy": "created_time"
},
"location": null
},
"matches": [
{
"name": "Lily",
"username": "lilyrose"
}
]
}
]
NewSearchAlertsEmail.PreviewProps = {
toUser: sinclairUser,
email: 'someone@gmail.com',
unsubscribeUrl: 'https://compassmeet.com/unsubscribe',
matches: matchSamples,
} as NewMessageEmailProps
export default NewSearchAlertsEmail

View File

@@ -1,4 +1,4 @@
import {Link, Row, Section, Text} from "@react-email/components";
import {Column, Img, Link, Row, Section, Text} from "@react-email/components";
interface Props {
email?: string
@@ -10,26 +10,54 @@ export const Footer = ({
unsubscribeUrl,
}: Props) => {
return <Section style={footer}>
<hr style={{border: 'none', borderTop: '1px solid #e0e0e0', margin: '20px 0'}}/>
<hr style={{border: 'none', borderTop: '1px solid #e0e0e0', margin: '10px 0'}}/>
<Row>
<Text style={footerText}>
Compass © {new Date().getFullYear()}
<Column align="center">
<Link href="https://github.com/CompassConnections/Compass" target="_blank">
<Img
src="https://cdn-icons-png.flaticon.com/512/733/733553.png"
width="24"
height="24"
alt="GitHub"
style={{ display: "inline-block", margin: "0 4px" }}
/>
</Link>
<Link href="https://discord.gg/8Vd7jzqjun" target="_blank">
<Img
src="https://cdn-icons-png.flaticon.com/512/2111/2111370.png"
width="24"
height="24"
alt="Discord"
style={{ display: "inline-block", margin: "0 4px" }}
/>
</Link>
<Link href="https://patreon.com/CompassMeet" target="_blank">
<Img
src="https://static.vecteezy.com/system/resources/previews/027/127/454/non_2x/patreon-logo-patreon-icon-transparent-free-png.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">
<Img
src="https://cdn-icons-png.flaticon.com/512/174/174861.png"
width="24"
height="24"
alt="PayPal"
style={{ display: "inline-block", margin: "0 4px" }}
/>
</Link>
</Column>
</Row>
<Row>
<Text style={{fontSize: "12px", color: "#888", marginTop: "12px"}}>
© {new Date().getFullYear()} Compass
</Text>
{/*<Row>*/}
{/* <Link href="https://github.com/CompassMeet/Compass">*/}
{/* <TbBrandGithub size={36} color={'black'}/>*/}
{/* </Link>*/}
{/* <Link href="https://discord.gg/8Vd7jzqjun">*/}
{/* <TbBrandDiscord size={36} color={'black'}/>*/}
{/* </Link>*/}
{/* <Link href="https://patreon.com/CompassMeet">*/}
{/* <TbBrandPatreon size={36} color={'black'}/>*/}
{/* </Link>*/}
{/* <Link href="https://www.paypal.com/paypalme/MartinBraquet">*/}
{/* <TbBrandPaypal size={36} color={'black'}/>*/}
{/* </Link>*/}
{/*</Row>*/}
<Text style={footerText}>
<Text style={{fontSize: "10px", color: "#888", marginTop: "12px"}}>
The email was sent to {email}. To no longer receive these emails, unsubscribe {' '}
<Link href={unsubscribeUrl}>
here
@@ -86,11 +114,11 @@ export const content = {
}
export const paragraph = {
fontSize: '18px',
// fontSize: '12px',
lineHeight: '24px',
margin: '10px 0',
color: '#000000',
fontFamily: 'Arial, Helvetica, sans-serif',
// fontFamily: 'Arial, Helvetica, sans-serif',
}
export const imageContainer = {

View File

@@ -8,11 +8,10 @@
},
"dependencies": {
"@react-email/components": "0.0.33",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-email": "3.0.7",
"react-icons": "5.5.0",
"resend": "4.1.2"
"resend": "4.1.2",
"react": "18.2.0",
"react-dom": "18.2.0"
},
"devDependencies": {
"@types/html-to-text": "9.0.4",

View File

@@ -8,11 +8,11 @@ import { chunk } from 'lodash'
runScript(async ({ pg }) => {
const directClient = createSupabaseDirectClient()
// Get all users and their corresponding lovers
// Get all users and their corresponding profiles
const users = await directClient.manyOrNone(`
select u.id, u.data, l.twitter
from users u
left join lovers l on l.user_id = u.id
left join profiles l on l.user_id = u.id
`)
log('Found', users.length, 'users to migrate')

View File

@@ -4,7 +4,7 @@ import {
select,
from,
where,
} from '../shared/src/supabase/sql-builder'
} from 'shared/supabase/sql-builder'
import { SupabaseDirectClient } from 'shared/supabase/init'
runScript(async ({ pg }) => {
@@ -27,7 +27,7 @@ const getNodes = async (pg: SupabaseDirectClient, nodeName: string) => {
console.log(`\nSearching comments for ${nodeName}...`)
const commentQuery = renderSql(
select('id, user_id, on_user_id, content'),
from('lover_comments'),
from('profile_comments'),
where(`jsonb_path_exists(content, '$.**.type ? (@ == "${nodeName}")')`)
)
const comments = await pg.manyOrNone(commentQuery)
@@ -59,7 +59,7 @@ const getNodes = async (pg: SupabaseDirectClient, nodeName: string) => {
console.log(`\nSearching profiles for ${nodeName}...`)
const users = renderSql(
select('user_id, bio'),
from('lovers'),
from('profiles'),
where(`jsonb_path_exists(bio::jsonb, '$.**.type ? (@ == "${nodeName}")')`)
)

View File

@@ -4,7 +4,7 @@ import {
select,
from,
where,
} from '../shared/src/supabase/sql-builder'
} from 'shared/supabase/sql-builder'
import { type JSONContent } from '@tiptap/core'
const removeNodesOfType = (
@@ -33,7 +33,7 @@ runScript(async ({ pg }) => {
console.log('\nSearching comments for linkPreviews...')
const commentQuery = renderSql(
select('id, content'),
from('lover_comments'),
from('profile_comments'),
where(`jsonb_path_exists(content, '$.**.type ? (@ == "${nodeType}")')`)
)
const comments = await pg.manyOrNone(commentQuery)
@@ -45,7 +45,7 @@ runScript(async ({ pg }) => {
console.log('before', comment.content)
console.log('after', newContent)
await pg.none('update lover_comments set content = $1 where id = $2', [
await pg.none('update profile_comments set content = $1 where id = $2', [
newContent,
comment.id,
])

View File

@@ -16,7 +16,7 @@
"@tiptap/html": "2.3.2",
"colors": "1.4.0",
"dayjs": "1.11.4",
"firebase-admin": "11.11.1",
"firebase-admin": "13.5.0",
"gcp-metadata": "6.1.0",
"lodash": "4.17.21",
"pg-promise": "11.4.1",

View File

@@ -4,15 +4,15 @@ import { createSupabaseDirectClient } from './supabase/init'
import { getNotificationDestinationsForUser } from 'common/user-notification-preferences'
import { Notification } from 'common/notifications'
import { insertNotificationToSupabase } from './supabase/notifications'
import { getLover } from './love/supabase'
import { getProfile } from './love/supabase'
export const createLoveLikeNotification = async (like: Row<'love_likes'>) => {
const { creator_id, target_id, like_id } = like
const targetPrivateUser = await getPrivateUser(target_id)
const lover = await getLover(creator_id)
const profile = await getProfile(creator_id)
if (!targetPrivateUser || !lover) return
if (!targetPrivateUser || !profile) return
const { sendToBrowser } = getNotificationDestinationsForUser(
targetPrivateUser,
@@ -30,9 +30,9 @@ export const createLoveLikeNotification = async (like: Row<'love_likes'>) => {
sourceId: like_id,
sourceType: 'love_like',
sourceUpdateType: 'created',
sourceUserName: lover.user.name,
sourceUserUsername: lover.user.username,
sourceUserAvatarUrl: lover.pinned_url ?? lover.user.avatarUrl,
sourceUserName: profile.user.name,
sourceUserUsername: profile.user.username,
sourceUserAvatarUrl: profile.pinned_url ?? profile.user.avatarUrl,
sourceText: '',
}
const pg = createSupabaseDirectClient()
@@ -48,13 +48,13 @@ export const createLoveShipNotification = async (
const creator = await getUser(creator_id)
const targetPrivateUser = await getPrivateUser(recipientId)
const lover = await getLover(otherTargetId)
const profile = await getProfile(otherTargetId)
if (!creator || !targetPrivateUser || !lover) {
if (!creator || !targetPrivateUser || !profile) {
console.error('Could not load user object', {
creator,
targetPrivateUser,
lover,
profile,
})
return
}
@@ -75,9 +75,9 @@ export const createLoveShipNotification = async (
sourceId: ship_id,
sourceType: 'love_ship',
sourceUpdateType: 'created',
sourceUserName: lover.user.name,
sourceUserUsername: lover.user.username,
sourceUserAvatarUrl: lover.pinned_url ?? lover.user.avatarUrl,
sourceUserName: profile.user.name,
sourceUserUsername: profile.user.username,
sourceUserAvatarUrl: profile.pinned_url ?? profile.user.avatarUrl,
sourceText: '',
data: {
creatorId: creator_id,

View File

@@ -1,37 +1,37 @@
import { areGenderCompatible } from 'common/love/compatibility-util'
import { type Lover, type LoverRow } from 'common/love/lover'
import { type Profile, type ProfileRow } from 'common/love/profile'
import { type User } from 'common/user'
import { Row } from 'common/supabase/utils'
import { createSupabaseDirectClient } from 'shared/supabase/init'
export type LoverAndUserRow = LoverRow & {
export type ProfileAndUserRow = ProfileRow & {
name: string
username: string
user: any
}
export function convertRow(row: LoverAndUserRow): Lover
export function convertRow(row: LoverAndUserRow | undefined): Lover | null {
export function convertRow(row: ProfileAndUserRow): Profile
export function convertRow(row: ProfileAndUserRow | undefined): Profile | null {
if (!row) return null
return {
...row,
user: { ...row.user, name: row.name, username: row.username } as User,
} as Lover
} as Profile
}
const LOVER_COLS = 'lovers.*, name, username, users.data as user'
const LOVER_COLS = 'profiles.*, name, username, users.data as user'
export const getLover = async (userId: string) => {
export const getProfile = async (userId: string) => {
const pg = createSupabaseDirectClient()
return await pg.oneOrNone(
`
select
${LOVER_COLS}
from
lovers
profiles
join
users on users.id = lovers.user_id
users on users.id = profiles.user_id
where
user_id = $1
`,
@@ -40,16 +40,16 @@ export const getLover = async (userId: string) => {
)
}
export const getLovers = async (userIds: string[]) => {
export const getProfiles = async (userIds: string[]) => {
const pg = createSupabaseDirectClient()
return await pg.map(
`
select
${LOVER_COLS}
from
lovers
profiles
join
users on users.id = lovers.user_id
users on users.id = profiles.user_id
where
user_id = any($1)
`,
@@ -58,30 +58,30 @@ export const getLovers = async (userIds: string[]) => {
)
}
export const getGenderCompatibleLovers = async (lover: LoverRow) => {
export const getGenderCompatibleProfiles = async (profile: ProfileRow) => {
const pg = createSupabaseDirectClient()
const lovers = await pg.map(
const profiles = await pg.map(
`
select
${LOVER_COLS}
from lovers
from profiles
join
users on users.id = lovers.user_id
users on users.id = profiles.user_id
where
user_id != $(user_id)
and looking_for_matches
and (data->>'isBannedFromPosting' != 'true' or data->>'isBannedFromPosting' is null)
and (data->>'userDeleted' != 'true' or data->>'userDeleted' is null)
and lovers.pinned_url is not null
and profiles.pinned_url is not null
`,
{ ...lover },
{ ...profile },
convertRow
)
return lovers.filter((l: Lover) => areGenderCompatible(lover, l))
return profiles.filter((l: Profile) => areGenderCompatible(profile, l))
}
export const getCompatibleLovers = async (
lover: LoverRow,
export const getCompatibleProfiles = async (
profile: ProfileRow,
radiusKm: number | undefined
) => {
const pg = createSupabaseDirectClient()
@@ -89,9 +89,9 @@ export const getCompatibleLovers = async (
`
select
${LOVER_COLS}
from lovers
from profiles
join
users on users.id = lovers.user_id
users on users.id = profiles.user_id
where
user_id != $(user_id)
and looking_for_matches
@@ -99,19 +99,19 @@ export const getCompatibleLovers = async (
and (data->>'userDeleted' != 'true' or data->>'userDeleted' is null)
-- Gender
and (lovers.gender = any($(pref_gender)) or lovers.gender = 'non-binary')
and ($(gender) = any(lovers.pref_gender) or $(gender) = 'non-binary')
and (profiles.gender = any($(pref_gender)) or profiles.gender = 'non-binary')
and ($(gender) = any(profiles.pref_gender) or $(gender) = 'non-binary')
-- Age
and lovers.age >= $(pref_age_min)
and lovers.age <= $(pref_age_max)
and $(age) >= lovers.pref_age_min
and $(age) <= lovers.pref_age_max
and profiles.age >= $(pref_age_min)
and profiles.age <= $(pref_age_max)
and $(age) >= profiles.pref_age_min
and $(age) <= profiles.pref_age_max
-- Location
and calculate_earth_distance_km($(city_latitude), $(city_longitude), lovers.city_latitude, lovers.city_longitude) < $(radiusKm)
and calculate_earth_distance_km($(city_latitude), $(city_longitude), profiles.city_latitude, profiles.city_longitude) < $(radiusKm)
`,
{ ...lover, radiusKm: radiusKm ?? 40_000 },
{ ...profile, radiusKm: radiusKm ?? 40_000 },
convertRow
)
}

View File

@@ -1,11 +1,8 @@
import {
createSupabaseDirectClient,
SupabaseDirectClient,
} from 'shared/supabase/init'
import {createSupabaseDirectClient, SupabaseDirectClient,} from 'shared/supabase/init'
import * as admin from 'firebase-admin'
import { convertPrivateUser, convertUser } from 'common/supabase/users'
import { log, type Logger } from 'shared/monitoring/log'
import { metrics } from 'shared/monitoring/metrics'
import {convertPrivateUser, convertUser} from 'common/supabase/users'
import {log, type Logger} from 'shared/monitoring/log'
import {metrics} from 'shared/monitoring/metrics'
export { metrics }
export { log, type Logger }
@@ -62,8 +59,7 @@ export const isProd = () => {
if (process.env.ENVIRONMENT) {
return process.env.ENVIRONMENT == 'PROD'
} else {
return admin.app().options.projectId === 'polylove'
return admin.app().options.projectId === 'compass-130ba'
}
}
export const LOCAL_DEV = process.env.GOOGLE_CLOUD_PROJECT == null

View File

@@ -1,7 +1,7 @@
import { Server as HttpServer } from 'node:http'
import { Server as WebSocketServer, RawData, WebSocket } from 'ws'
import { isError } from 'lodash'
import { LOCAL_DEV, log, metrics } from 'shared/utils'
import { log, metrics } from 'shared/utils'
import { Switchboard } from './switchboard'
import {
BroadcastPayload,
@@ -9,6 +9,7 @@ import {
ServerMessage,
CLIENT_MESSAGE_SCHEMA,
} from 'common/api/websockets'
import {LOCAL_DEV} from "common/envs/constants";
const SWITCHBOARD = new Switchboard()

View File

@@ -0,0 +1,40 @@
CREATE TABLE IF NOT EXISTS bookmarked_searches (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
creator_id TEXT NOT NULL,
search_filters JSONB,
location JSONB,
last_notified_at TIMESTAMPTZ DEFAULT NULL,
search_name TEXT DEFAULT NULL
);
-- Row Level Security
ALTER TABLE bookmarked_searches ENABLE ROW LEVEL SECURITY;
-- Policies
DROP POLICY IF EXISTS "public read" ON bookmarked_searches;
CREATE POLICY "public read" ON bookmarked_searches
FOR SELECT USING (true);
DROP POLICY IF EXISTS "self delete" ON bookmarked_searches;
CREATE POLICY "self delete" ON bookmarked_searches
FOR DELETE USING (creator_id = firebase_uid());
DROP POLICY IF EXISTS "self insert" ON bookmarked_searches;
CREATE POLICY "self insert" ON bookmarked_searches
FOR INSERT WITH CHECK (creator_id = firebase_uid());
DROP POLICY IF EXISTS "self update" ON bookmarked_searches;
CREATE POLICY "self update" ON bookmarked_searches
FOR UPDATE USING (creator_id = firebase_uid());
-- Indexes
CREATE INDEX IF NOT EXISTS bookmarked_searches_creator_id_created_time_idx
ON public.bookmarked_searches (creator_id, created_time DESC);
CREATE INDEX IF NOT EXISTS bookmarked_searches_creator_id_idx
ON public.bookmarked_searches (creator_id);
CREATE INDEX IF NOT EXISTS bookmarked_searches_search_name_idx
ON public.bookmarked_searches (search_name);

View File

@@ -1,4 +1,3 @@
-- This file is autogenerated from regen-schema.ts
create
or replace function public.to_jsonb (jsonb) returns jsonb language sql immutable parallel SAFE strict as $function$ select $1 $function$;

View File

@@ -20,7 +20,7 @@ END;
$function$;
create
or replace function public.get_love_question_answers_and_lovers (p_question_id bigint) returns setof record language plpgsql as $function$
or replace function public.get_love_question_answers_and_profiles (p_question_id bigint) returns setof record language plpgsql as $function$
BEGIN
RETURN QUERY
SELECT
@@ -29,16 +29,16 @@ SELECT
love_answers.free_response,
love_answers.multiple_choice,
love_answers.integer,
lovers.age,
lovers.gender,
lovers.city,
profiles.age,
profiles.gender,
profiles.city,
users.data
FROM
lovers
profiles
JOIN
love_answers ON lovers.user_id = love_answers.creator_id
love_answers ON profiles.user_id = love_answers.creator_id
join
users on lovers.user_id = users.id
users on profiles.user_id = users.id
WHERE
love_answers.question_id = p_question_id
order by love_answers.created_time desc;

View File

@@ -1,4 +1,3 @@
-- This file is autogenerated from regen-schema.ts
CREATE TABLE IF NOT EXISTS love_answers (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,

View File

@@ -1,4 +1,3 @@
-- This file is autogenerated from regen-schema.ts
CREATE TABLE IF NOT EXISTS love_compatibility_answers (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,

View File

@@ -1,4 +1,3 @@
-- This file is autogenerated from regen-schema.ts
CREATE TABLE IF NOT EXISTS love_likes (
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
creator_id TEXT NOT NULL,

View File

@@ -1,4 +1,3 @@
-- This file is autogenerated from regen-schema.ts
CREATE TABLE IF NOT EXISTS love_questions (
answer_type TEXT DEFAULT 'free_response' NOT NULL,
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,

View File

@@ -1,4 +1,3 @@
-- This file is autogenerated from regen-schema.ts
CREATE TABLE IF NOT EXISTS love_ships (
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
creator_id TEXT NOT NULL,

View File

@@ -1,4 +1,3 @@
-- This file is autogenerated from regen-schema.ts
CREATE TABLE IF NOT EXISTS love_stars (
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
creator_id TEXT NOT NULL,

View File

@@ -1,4 +1,3 @@
-- This file is autogenerated from regen-schema.ts
CREATE TABLE IF NOT EXISTS love_waitlist (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,

View File

@@ -1,79 +0,0 @@
-- This file is autogenerated from regen-schema.ts
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'lover_visibility') THEN
CREATE TYPE lover_visibility AS ENUM ('public', 'member');
END IF;
END$$;
CREATE TABLE IF NOT EXISTS lovers (
age INTEGER DEFAULT 18 NOT NULL,
bio JSON,
born_in_location TEXT,
city TEXT NOT NULL,
city_latitude NUMERIC(9, 6),
city_longitude NUMERIC(9, 6),
comments_enabled BOOLEAN DEFAULT TRUE NOT NULL,
company TEXT,
country TEXT,
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
drinks_per_month INTEGER,
education_level TEXT,
ethnicity TEXT[],
gender TEXT NOT NULL,
geodb_city_id TEXT,
has_kids INTEGER,
height_in_inches INTEGER,
id BIGINT GENERATED ALWAYS AS IDENTITY NOT NULL,
is_smoker BOOLEAN,
is_vegetarian_or_vegan BOOLEAN,
last_online_time TIMESTAMPTZ DEFAULT now() NOT NULL,
looking_for_matches BOOLEAN DEFAULT TRUE NOT NULL,
messaging_status TEXT DEFAULT 'open'::TEXT NOT NULL,
occupation TEXT,
occupation_title TEXT,
photo_urls TEXT[],
pinned_url TEXT,
political_beliefs TEXT[],
pref_age_max INTEGER DEFAULT 100 NOT NULL,
pref_age_min INTEGER DEFAULT 18 NOT NULL,
pref_gender TEXT[] NOT NULL,
pref_relation_styles TEXT[] NOT NULL,
referred_by_username TEXT,
region_code TEXT,
religious_belief_strength INTEGER,
religious_beliefs TEXT,
twitter TEXT,
university TEXT,
user_id TEXT NOT NULL,
visibility lover_visibility DEFAULT 'member'::lover_visibility NOT NULL,
wants_kids_strength INTEGER DEFAULT 0 NOT NULL,
website TEXT,
CONSTRAINT lovers_pkey PRIMARY KEY (id)
);
-- Row Level Security
ALTER TABLE lovers ENABLE ROW LEVEL SECURITY;
-- Policies
DROP POLICY IF EXISTS "public read" ON lovers;
CREATE POLICY "public read" ON lovers
FOR SELECT
USING (true);
DROP POLICY IF EXISTS "self update" ON lovers;
CREATE POLICY "self update" ON lovers
FOR UPDATE
WITH CHECK ((user_id = firebase_uid()));
-- Indexes
DROP INDEX IF EXISTS lovers_user_id_idx;
CREATE INDEX lovers_user_id_idx ON public.lovers USING btree (user_id);
DROP INDEX IF EXISTS unique_user_id;
CREATE UNIQUE INDEX unique_user_id ON public.lovers USING btree (user_id);

View File

@@ -1,7 +1,7 @@
BEGIN;
\i backend/supabase/functions.sql
\i backend/supabase/firebase.sql
\i backend/supabase/lovers.sql
\i backend/supabase/profiles.sql
\i backend/supabase/users.sql
\i backend/supabase/private_user_message_channels.sql
\i backend/supabase/private_user_message_channel_members.sql
@@ -9,7 +9,7 @@ BEGIN;
\i backend/supabase/private_user_messages.sql
\i backend/supabase/private_user_seen_message_channels.sql
\i backend/supabase/love_answers.sql
\i backend/supabase/lover_comments.sql
\i backend/supabase/profile_comments.sql
\i backend/supabase/love_compatibility_answers.sql
\i backend/supabase/love_likes.sql
\i backend/supabase/love_questions.sql
@@ -20,7 +20,5 @@ BEGIN;
\i backend/supabase/user_notifications.sql
\i backend/supabase/functions_others.sql
\i backend/supabase/reports.sql
\i backend/supabase/migrations/20250910_bio_to_jsonb.sql
\i backend/supabase/migrations/20250910_add_bio_text.sql
\i backend/supabase/migrations/20250910_pg_trgm.sql
\i backend/supabase/bookmarked_searches.sql
COMMIT;

View File

@@ -1,22 +0,0 @@
ALTER TABLE lovers ADD COLUMN bio_text tsvector;
CREATE OR REPLACE FUNCTION lovers_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 lovers_bio_tsvector_trigger
BEFORE INSERT OR UPDATE OF bio ON lovers
FOR EACH ROW EXECUTE FUNCTION lovers_bio_tsvector_update();
create index on lovers using gin(bio_text);

View File

@@ -1,5 +0,0 @@
-- 1. Drop the old column
alter table lovers drop column if exists bio;
-- 2. Add the new column as jsonb
alter table lovers add column bio jsonb;

View File

@@ -1,4 +0,0 @@
create extension if not exists pg_trgm;
CREATE INDEX lovers_bio_trgm_idx
ON lovers USING gin ((bio::text) gin_trgm_ops);

View File

@@ -1,4 +1,3 @@
-- This file is autogenerated from regen-schema.ts
CREATE TABLE IF NOT EXISTS private_user_message_channel_members (
channel_id BIGINT NOT NULL,
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,

View File

@@ -1,4 +1,3 @@
-- This file is autogenerated from regen-schema.ts
CREATE TABLE IF NOT EXISTS private_user_message_channels (
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
id BIGINT GENERATED ALWAYS AS IDENTITY NOT NULL,

View File

@@ -1,4 +1,3 @@
-- This file is autogenerated from regen-schema.ts
CREATE TABLE IF NOT EXISTS private_user_messages (
channel_id BIGINT NOT NULL,
content JSONB NOT NULL,

View File

@@ -1,4 +1,3 @@
-- This file is autogenerated from regen-schema.ts
CREATE TABLE IF NOT EXISTS private_user_seen_message_channels (
channel_id BIGINT NOT NULL,
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,

View File

@@ -1,4 +1,3 @@
-- This file is autogenerated from regen-schema.ts
CREATE TABLE IF NOT EXISTS private_users (
data JSONB NOT NULL,
id TEXT NOT NULL,

View File

@@ -1,5 +1,4 @@
-- This file is autogenerated from regen-schema.ts
CREATE TABLE IF NOT EXISTS lover_comments (
CREATE TABLE IF NOT EXISTS profile_comments (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
content JSONB NOT NULL,
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
@@ -13,12 +12,12 @@ CREATE TABLE IF NOT EXISTS lover_comments (
);
-- Row Level Security
ALTER TABLE lover_comments ENABLE ROW LEVEL SECURITY;
ALTER TABLE profile_comments ENABLE ROW LEVEL SECURITY;
-- Policies
DROP POLICY IF EXISTS "public read" ON lover_comments;
CREATE POLICY "public read" ON lover_comments FOR ALL USING (true);
DROP POLICY IF EXISTS "public read" ON profile_comments;
CREATE POLICY "public read" ON profile_comments FOR ALL USING (true);
-- Indexes
CREATE INDEX IF NOT EXISTS lover_comments_user_id_idx
ON public.lover_comments USING btree (on_user_id);
CREATE INDEX IF NOT EXISTS profile_comments_user_id_idx
ON public.profile_comments USING btree (on_user_id);

View File

@@ -0,0 +1,134 @@
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'profile_visibility') THEN
CREATE TYPE profile_visibility AS ENUM ('public', 'member');
END IF;
END$$;
CREATE TABLE IF NOT EXISTS profiles (
age INTEGER NULL,
bio JSONB,
born_in_location TEXT,
city TEXT NOT NULL,
city_latitude NUMERIC(9, 6),
city_longitude NUMERIC(9, 6),
comments_enabled BOOLEAN DEFAULT TRUE NOT NULL,
company TEXT,
country TEXT,
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
drinks_per_month INTEGER,
education_level TEXT,
ethnicity TEXT[],
gender TEXT NOT NULL,
geodb_city_id TEXT,
has_kids INTEGER,
height_in_inches INTEGER,
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,
occupation TEXT,
occupation_title TEXT,
photo_urls TEXT[],
pinned_url TEXT,
political_beliefs TEXT[],
pref_age_max INTEGER NULL,
pref_age_min INTEGER NULL,
pref_gender TEXT[] NOT NULL,
pref_relation_styles TEXT[] NOT NULL,
referred_by_username TEXT,
region_code TEXT,
religious_belief_strength INTEGER,
religious_beliefs TEXT,
twitter TEXT,
university TEXT,
user_id TEXT NOT NULL,
visibility profile_visibility DEFAULT 'member'::profile_visibility NOT NULL,
wants_kids_strength INTEGER DEFAULT 0 NOT NULL,
website TEXT,
CONSTRAINT profiles_pkey PRIMARY KEY (id)
);
-- Row Level Security
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);
DROP POLICY IF EXISTS "self update" ON profiles;
CREATE POLICY "self update" ON profiles
FOR UPDATE
WITH CHECK ((user_id = firebase_uid()));
-- Indexes
DROP INDEX IF EXISTS profiles_user_id_idx;
CREATE INDEX profiles_user_id_idx ON public.profiles USING btree (user_id);
DROP INDEX IF EXISTS unique_user_id;
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);
-- 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;
END;
$$
LANGUAGE plpgsql;
CREATE TRIGGER trigger_update_last_mod_time
BEFORE UPDATE
ON profiles
FOR EACH ROW
EXECUTE FUNCTION update_last_modification_time();
-- pg_trgm
create extension if not exists pg_trgm;
CREATE INDEX profiles_bio_trgm_idx
ON profiles USING gin ((bio::text) gin_trgm_ops);
--- 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);

View File

@@ -1,4 +1,3 @@
-- This file is autogenerated from regen-schema.ts
create table if not exists
reports (
content_id text not null,

View File

@@ -1,4 +1,3 @@
-- This file is autogenerated from regen-schema.ts
create table if not exists
temp_users (
created_time timestamp with time zone,

View File

@@ -1,4 +1,3 @@
-- This file is autogenerated from regen-schema.ts
CREATE TABLE IF NOT EXISTS user_events (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
ad_id TEXT,

View File

@@ -1,4 +1,3 @@
-- This file is autogenerated from regen-schema.ts
CREATE TABLE IF NOT EXISTS user_notifications (
notification_id TEXT NOT NULL,
user_id TEXT NOT NULL,

View File

@@ -1,4 +1,3 @@
-- This file is autogenerated from regen-schema.ts
CREATE TABLE IF NOT EXISTS users (
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
data JSONB NOT NULL,

View File

@@ -1 +1 @@
-- This file is autogenerated from regen-schema.ts

View File

@@ -1,13 +1,13 @@
import {
contentSchema,
combinedLoveUsersSchema,
baseLoversSchema,
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 { Lover, LoverRow } from 'common/love/lover'
import { Profile, ProfileRow } from 'common/love/profile'
import { Row } from 'common/supabase/utils'
import { PrivateUser, User } from 'common/user'
import { z } from 'zod'
@@ -88,11 +88,11 @@ export const API = (_apiTypeCheck = {
})
.strict(),
},
'create-lover': {
'create-profile': {
method: 'POST',
authed: true,
returns: {} as Row<'lovers'>,
props: baseLoversSchema,
returns: {} as Row<'profiles'>,
props: baseProfilesSchema,
},
report: {
method: 'POST',
@@ -143,11 +143,11 @@ export const API = (_apiTypeCheck = {
}),
returns: {} as FullUser,
},
'update-lover': {
'update-profile': {
method: 'POST',
authed: true,
props: combinedLoveUsersSchema.partial(),
returns: {} as LoverRow,
returns: {} as ProfileRow,
},
'update-notif-settings': {
method: 'POST',
@@ -212,14 +212,14 @@ export const API = (_apiTypeCheck = {
})
.strict(),
},
'compatible-lovers': {
'compatible-profiles': {
method: 'GET',
authed: false,
props: z.object({ userId: z.string() }),
returns: {} as {
lover: Lover
compatibleLovers: Lover[]
loverCompatibilityScores: {
profile: Profile
compatibleProfiles: Profile[]
profileCompatibilityScores: {
[userId: string]: CompatibilityScore
}
},
@@ -246,7 +246,7 @@ export const API = (_apiTypeCheck = {
})[]
},
},
'like-lover': {
'like-profile': {
method: 'POST',
authed: true,
props: z.object({
@@ -257,7 +257,7 @@ export const API = (_apiTypeCheck = {
status: 'success'
},
},
'ship-lovers': {
'ship-profiles': {
method: 'POST',
authed: true,
props: z.object({
@@ -293,7 +293,7 @@ export const API = (_apiTypeCheck = {
hasFreeLike: boolean
},
},
'star-lover': {
'star-profile': {
method: 'POST',
authed: true,
props: z.object({
@@ -304,7 +304,7 @@ export const API = (_apiTypeCheck = {
status: 'success'
},
},
'get-lovers': {
'get-profiles': {
method: 'GET',
authed: false,
props: z
@@ -331,10 +331,10 @@ export const API = (_apiTypeCheck = {
.strict(),
returns: {} as {
status: 'success' | 'fail'
lovers: Lover[]
profiles: Profile[]
},
},
'get-lover-answers': {
'get-profile-answers': {
method: 'GET',
authed: false,
props: z.object({ userId: z.string() }).strict(),

View File

@@ -1,5 +1,4 @@
import { ENV_CONFIG } from 'common/envs/constants'
import { type APIPath } from './schema'
type ErrorCode =
| 400 // your input is bad (like zod is mad)
@@ -20,8 +19,8 @@ export class APIError extends Error {
}
}
export function pathWithPrefix(path: APIPath) {
return `v0/${path}`
export function pathWithPrefix(path: string) {
return `/v0${path}`
}
export function getWebsocketUrl() {

View File

@@ -42,13 +42,13 @@ const genderType = z.string()
// ])
const genderTypes = z.array(genderType)
export const baseLoversSchema = z.object({
export const baseProfilesSchema = z.object({
// Required fields
age: z.number().min(18).max(100),
age: z.number().min(18).max(100).optional(),
gender: genderType,
pref_gender: genderTypes,
pref_age_min: z.number().min(18).max(999),
pref_age_max: z.number().min(18).max(1000),
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'),
@@ -74,7 +74,7 @@ export const baseLoversSchema = z.object({
referred_by_username: z.string().optional(),
})
const optionalLoversSchema = z.object({
const optionalProfilesSchema = z.object({
political_beliefs: z.array(z.string()).optional(),
religious_belief_strength: z.number().optional(),
religious_beliefs: z.string().optional(),
@@ -100,4 +100,4 @@ const optionalLoversSchema = z.object({
})
export const combinedLoveUsersSchema =
baseLoversSchema.merge(optionalLoversSchema)
baseProfilesSchema.merge(optionalProfilesSchema)

View File

@@ -11,8 +11,8 @@ export type Comment = {
replyToCommentId?: string
userId: string
// lover
commentType: 'lover'
// profile
commentType: 'profile'
onUserId: string
/** @deprecated - content now stored as JSON in content*/

2
common/src/constants.ts Normal file
View File

@@ -0,0 +1,2 @@
export const MAX_INT = 99999
export const MIN_INT = -MAX_INT

View File

@@ -1,5 +1,5 @@
import { DEV_CONFIG } from './dev'
import { EnvConfig, PROD_CONFIG } from './prod'
import {DEV_CONFIG} from './dev'
import {EnvConfig, PROD_CONFIG} from './prod'
// Valid in web client & Vercel deployments only.
export const ENV = (process.env.NEXT_PUBLIC_FIREBASE_ENV ?? 'PROD') as
@@ -107,3 +107,6 @@ export const RESERVED_PATHS = [
'web',
'welcome',
]
export const LOCAL_WEB_URL = 'http://localhost:3000';
export const LOCAL_DEV = process.env.GOOGLE_CLOUD_PROJECT == null

View File

@@ -2,4 +2,17 @@ import { EnvConfig, PROD_CONFIG } from './prod'
export const DEV_CONFIG: EnvConfig = {
...PROD_CONFIG,
supabaseInstanceId: 'zbspxezubpzxmuxciurg',
supabaseAnonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inpic3B4ZXp1YnB6eG11eGNpdXJnIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTc2ODM0MTMsImV4cCI6MjA3MzI1OTQxM30.ZkM7zlawP8Nke0T3KJrqpOQ4DzqPaXTaJXLC2WU8Y7c',
firebaseConfig: {
apiKey: "AIzaSyBspL9glBXWbMsjmtt36dgb2yU0YGGhzKo",
authDomain: "compass-57c3c.firebaseapp.com",
projectId: "compass-57c3c",
storageBucket: "compass-57c3c.firebasestorage.app",
privateBucket: 'compass-private.firebasestorage.app',
messagingSenderId: "297460199314",
appId: "1:297460199314:web:c45678c54285910e255b4b",
measurementId: "G-N6LZ64EMJ2",
region: 'us-west1',
}
}

View File

@@ -30,7 +30,7 @@ type FirebaseConfig = {
}
export const PROD_CONFIG: EnvConfig = {
posthogKey: 'phc_xT16KyBj7GsWnAwifoH4HiWKTFhuohRrfy3t5DK6ZIv',
posthogKey: 'phc_tFvQzHiMVdaAIgE38xqYomMN8q8SB5K45fqmkKNjfBU',
domain: 'compassmeet.com',
firebaseConfig: {
apiKey: process.env.NEXT_PUBLIC_FIREBASE_API_KEY || '',

52
common/src/filters.ts Normal file
View File

@@ -0,0 +1,52 @@
import {Profile, ProfileRow} from "common/love/profile";
import {cloneDeep} from "lodash";
import {filterDefined} from "common/util/array";
export type FilterFields = {
orderBy: 'last_online_time' | 'created_time' | 'compatibility_score'
geodbCityIds: string[] | null
genders: string[]
name: string | undefined
} & Pick<
ProfileRow,
| 'wants_kids_strength'
| 'pref_relation_styles'
| 'is_smoker'
| 'has_kids'
| 'pref_gender'
| 'pref_age_min'
| 'pref_age_max'
>
export const orderProfiles = (
profiles: Profile[],
starredUserIds: string[] | undefined
) => {
if (!profiles) return
let s = cloneDeep(profiles)
if (starredUserIds) {
s = filterDefined([
...starredUserIds.map((id) => s.find((l) => l.user_id === id)),
...s.filter((l) => !starredUserIds.includes(l.user_id)),
])
}
// s = alternateWomenAndMen(s)
return s
}
export const initialFilters: Partial<FilterFields> = {
geodbCityIds: undefined,
name: undefined,
genders: undefined,
pref_age_max: undefined,
pref_age_min: undefined,
has_kids: undefined,
wants_kids_strength: undefined,
is_smoker: undefined,
pref_relation_styles: undefined,
pref_gender: undefined,
orderBy: 'created_time',
}
export type OriginLocation = { id: string; name: string }

51
common/src/has-kids.ts Normal file
View File

@@ -0,0 +1,51 @@
export interface HasKidLabel {
name: string
shortName: string
value: number
}
export interface HasKidsLabelsMap {
[key: string]: HasKidLabel
}
export const hasKidsLabels: HasKidsLabelsMap = {
no_preference: {
name: 'Any kids',
shortName: 'Any kids',
value: -1,
},
has_kids: {
name: 'Has kids',
shortName: 'Yes',
value: 1,
},
doesnt_have_kids: {
name: `Doesn't have kids`,
shortName: 'No',
value: 0,
},
}
export const hasKidsNames = Object.values(hasKidsLabels).reduce<Record<number, string>>(
(acc, {value, name}) => {
acc[value] = name
return acc
},
{}
)
export const generateChoicesMap = (
labels: HasKidsLabelsMap
): Record<string, number> => {
return Object.values(labels).reduce(
(acc: Record<string, number>, label: HasKidLabel) => {
acc[label.shortName] = label.value
return acc
},
{}
)
}
// export const NO_PREFERENCE_STRENGTH = -1
// export const WANTS_KIDS_STRENGTH = 2
// export const DOESNT_WANT_KIDS_STRENGTH = 0

View File

@@ -0,0 +1,26 @@
export interface MatchPrivateUser {
email: string
notificationPreferences: any
}
export interface MatchUser {
name: string
username: string
}
export interface MatchesType {
description: {
filters: any; // You might want to replace 'any' with a more specific type
location: any; // You might want to replace 'any' with a more specific type
};
matches: MatchUser[]; // You might want to replace 'any' with a more specific type
id: string
}
export interface MatchesByUserType {
[key: string]: {
user: any;
privateUser: any;
matches: MatchesType[];
}
}

View File

@@ -1,5 +1,5 @@
import { keyBy, sumBy } from 'lodash'
import { LoverRow } from 'common/love/lover'
import { ProfileRow } from 'common/love/profile'
import { Row as rowFor } from 'common/supabase/utils'
import {
areAgeCompatible,
@@ -132,14 +132,14 @@ export function getScoredAnswerCompatibility(
)
}
export const getLoversCompatibilityFactor = (
lover1: LoverRow,
lover2: LoverRow
export const getProfilesCompatibilityFactor = (
profile1: ProfileRow,
profile2: ProfileRow
) => {
let multiplier = 1
multiplier *= areAgeCompatible(lover1, lover2) ? 1 : 0.5
multiplier *= areRelationshipStyleCompatible(lover1, lover2) ? 1 : 0.5
multiplier *= areWantKidsCompatible(lover1, lover2) ? 1 : 0.5
multiplier *= areLocationCompatible(lover1, lover2) ? 1 : 0.1
multiplier *= areAgeCompatible(profile1, profile2) ? 1 : 0.5
multiplier *= areRelationshipStyleCompatible(profile1, profile2) ? 1 : 0.5
multiplier *= areWantKidsCompatible(profile1, profile2) ? 1 : 0.5
multiplier *= areLocationCompatible(profile1, profile2) ? 1 : 0.1
return multiplier
}

View File

@@ -1,4 +1,5 @@
import { LoverRow } from 'common/love/lover'
import { ProfileRow } from 'common/love/profile'
import {MAX_INT, MIN_INT} from "common/constants";
const isPreferredGender = (
preferredGenders: string[] | undefined,
@@ -17,53 +18,53 @@ const isPreferredGender = (
return preferredGenders.includes(gender) || gender === 'non-binary'
}
export const areGenderCompatible = (lover1: LoverRow, lover2: LoverRow) => {
// console.log('areGenderCompatible', isPreferredGender(lover1.pref_gender, lover2.gender), isPreferredGender(lover2.pref_gender, lover1.gender))
export const areGenderCompatible = (profile1: ProfileRow, profile2: ProfileRow) => {
// console.log('areGenderCompatible', isPreferredGender(profile1.pref_gender, profile2.gender), isPreferredGender(profile2.pref_gender, profile1.gender))
return (
isPreferredGender(lover1.pref_gender, lover2.gender) &&
isPreferredGender(lover2.pref_gender, lover1.gender)
isPreferredGender(profile1.pref_gender, profile2.gender) &&
isPreferredGender(profile2.pref_gender, profile1.gender)
)
}
const satisfiesAgeRange = (lover: LoverRow, age: number) => {
return age >= lover.pref_age_min && age <= lover.pref_age_max
const satisfiesAgeRange = (profile: ProfileRow, age: number | null | undefined) => {
return (age ?? MAX_INT) >= (profile.pref_age_min ?? MIN_INT) && (age ?? MIN_INT) <= (profile.pref_age_max ?? MAX_INT)
}
export const areAgeCompatible = (lover1: LoverRow, lover2: LoverRow) => {
export const areAgeCompatible = (profile1: ProfileRow, profile2: ProfileRow) => {
return (
satisfiesAgeRange(lover1, lover2.age) &&
satisfiesAgeRange(lover2, lover1.age)
satisfiesAgeRange(profile1, profile2.age) &&
satisfiesAgeRange(profile2, profile1.age)
)
}
export const areLocationCompatible = (lover1: LoverRow, lover2: LoverRow) => {
export const areLocationCompatible = (profile1: ProfileRow, profile2: ProfileRow) => {
if (
!lover1.city_latitude ||
!lover2.city_latitude ||
!lover1.city_longitude ||
!lover2.city_longitude
!profile1.city_latitude ||
!profile2.city_latitude ||
!profile1.city_longitude ||
!profile2.city_longitude
)
return lover1.city.trim().toLowerCase() === lover2.city.trim().toLowerCase()
return profile1.city.trim().toLowerCase() === profile2.city.trim().toLowerCase()
const latitudeDiff = Math.abs(lover1.city_latitude - lover2.city_latitude)
const longigudeDiff = Math.abs(lover1.city_longitude - lover2.city_longitude)
const latitudeDiff = Math.abs(profile1.city_latitude - profile2.city_latitude)
const longigudeDiff = Math.abs(profile1.city_longitude - profile2.city_longitude)
const root = (latitudeDiff ** 2 + longigudeDiff ** 2) ** 0.5
return root < 2.5
}
export const areRelationshipStyleCompatible = (
lover1: LoverRow,
lover2: LoverRow
profile1: ProfileRow,
profile2: ProfileRow
) => {
return lover1.pref_relation_styles.some((style) =>
lover2.pref_relation_styles.includes(style)
return profile1.pref_relation_styles.some((style) =>
profile2.pref_relation_styles.includes(style)
)
}
export const areWantKidsCompatible = (lover1: LoverRow, lover2: LoverRow) => {
const { wants_kids_strength: kids1 } = lover1
const { wants_kids_strength: kids2 } = lover2
export const areWantKidsCompatible = (profile1: ProfileRow, profile2: ProfileRow) => {
const { wants_kids_strength: kids1 } = profile1
const { wants_kids_strength: kids2 } = profile2
if (kids1 === undefined || kids2 === undefined) return true

View File

@@ -1,10 +0,0 @@
import { Row, run, SupabaseClient } from 'common/supabase/utils'
import { User } from 'common/user'
export type LoverRow = Row<'lovers'>
export type Lover = LoverRow & { user: User }
export const getLoverRow = async (userId: string, db: SupabaseClient) => {
console.log('getLoverRow', userId)
const res = await run(db.from('lovers').select('*').eq('user_id', userId))
return res.data[0]
}

View File

@@ -1,5 +1,5 @@
import { User } from 'common/user'
import { LoverRow } from 'common/love/lover'
import { ProfileRow } from 'common/love/profile'
import { buildOgUrl } from 'common/util/og'
// TODO: handle age, gender undefined better
@@ -8,21 +8,21 @@ export type LoveOgProps = {
avatarUrl: string
username: string
name: string
// lover props
// profile props
age: string
city: string
gender: string
}
export function getLoveOgImageUrl(user: User, lover?: LoverRow | null) {
export function getLoveOgImageUrl(user: User, profile?: ProfileRow | null) {
const loveProps = {
avatarUrl: lover?.pinned_url,
avatarUrl: profile?.pinned_url,
username: user.username,
name: user.name,
age: lover?.age.toString() ?? '25',
city: lover?.city ?? 'Internet',
gender: lover?.gender ?? '???',
age: profile?.age?.toString() ?? '25',
city: profile?.city ?? 'Internet',
gender: profile?.gender ?? '???',
} as LoveOgProps
return buildOgUrl(loveProps, 'lover', 'compassmeet.com')
return buildOgUrl(loveProps, 'profile')
}

View File

@@ -0,0 +1,10 @@
import { Row, run, SupabaseClient } from 'common/supabase/utils'
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)
const res = await run(db.from('profiles').select('*').eq('user_id', userId))
return res.data[0]
}

View File

@@ -31,7 +31,7 @@ export type Notification = {
export const NOTIFICATION_TYPES_TO_SELECT = [
'new_match', // new match markets
'comment_on_lover', // endorsements
'comment_on_profile', // endorsements
'love_like',
'love_ship',
]

87
common/src/searches.ts Normal file
View File

@@ -0,0 +1,87 @@
// Define nice labels for each key
import {FilterFields, initialFilters} from "common/filters";
import {wantsKidsNames} from "common/wants-kids";
import {hasKidsNames} from "common/has-kids";
const filterLabels: Record<string, string> = {
geodbCityIds: "",
location: "",
name: "Searching",
genders: "",
pref_age_max: "Max age",
pref_age_min: "Min age",
has_kids: "",
wants_kids_strength: "Kids",
is_smoker: "",
pref_relation_styles: "Seeking",
pref_gender: "",
orderBy: "",
}
export type locationType = {
location: {
name: string
}
radius: number
}
export function formatFilters(filters: Partial<FilterFields>, location: locationType | null): String[] | null {
const entries: String[] = []
let ageEntry = null
let ageMin: number | undefined | null = filters.pref_age_min
if (ageMin == 18) ageMin = undefined
let ageMax = filters.pref_age_max;
if (ageMax == 100) ageMax = undefined
if (ageMin || ageMax) {
let text: string = 'Age: '
if (ageMin) text = `${text}${ageMin}`
if (ageMax) {
if (ageMin) {
text = `${text}-${ageMax}`
} else {
text = `${text}up to ${ageMax}`
}
} else {
text = `${text}+`
}
ageEntry = text
}
Object.entries(filters).forEach(([key, value]) => {
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 (Array.isArray(value) && value.length === 0) return
if (initialFilters[typedKey] === value) return
const label = filterLabels[typedKey] ?? key
let stringValue = value
if (key === 'has_kids') stringValue = hasKidsNames[value as number]
if (key === 'wants_kids_strength') stringValue = wantsKidsNames[value as number]
if (Array.isArray(value)) stringValue = value.join(', ')
if (!label) {
const str = String(stringValue)
stringValue = str.charAt(0).toUpperCase() + str.slice(1)
}
const display = stringValue
entries.push(`${label}${label ? ': ' : ''}${display}`)
})
if (ageEntry) entries.push(ageEntry)
if (location?.location?.name) {
const locString = `${location?.location?.name} (${location?.radius}mi)`
entries.push(locString)
}
if (entries.length === 0) return ['Anyone']
return entries
}

View File

@@ -15,6 +15,7 @@ export const secrets = (
'TEST_CREATE_USER_KEY',
'GEODB_API_KEY',
'RESEND_KEY',
'COMPASS_API_KEY',
'NEXT_PUBLIC_FIREBASE_API_KEY',
// Some typescript voodoo to keep the string literal types while being not readonly.
] as const

View File

@@ -42,8 +42,6 @@ const stripper: { [key in Site]: (input: string) => string } = {
s
.replace(/^(https?:\/\/)?(www\.)?(\w+\.)?substack\.com\//, '')
.replace(/\/$/, ''),
// onlyfans: (s) =>
// s.replace(/^(https?:\/\/)?(www\.)?onlyfans\.com\//, '').replace(/\/$/, ''),
instagram: (s) =>
s
.replace(/^(https?:\/\/)?(www\.)?instagram\.com\//, '')
@@ -80,13 +78,12 @@ const urler: { [key in Site]: (handle: string) => string } = {
mastodon: (s) =>
s.includes('@') ? `https://${s.split('@')[1]}/@${s.split('@')[0]}` : s,
substack: (s) => `https://${s}.substack.com`,
// onlyfans: (s) => `https://onlyfans.com/${s}`,
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/user/${s}`,
paypal: (s) => `https://paypal.com/paypalme/${s}`,
patreon: (s) => `https://patreon.com/user/${s}`,
}
@@ -97,7 +94,6 @@ export const PLATFORM_LABELS: { [key in Site]: string } = {
bluesky: 'Bluesky',
mastodon: 'Mastodon',
substack: 'Substack',
// onlyfans: 'OnlyFans',
instagram: 'Instagram',
github: 'GitHub',
linkedin: 'LinkedIn',

View File

@@ -2,10 +2,10 @@ import { type JSONContent } from '@tiptap/core'
import { type Row, tsToMillis } from './utils'
import { type Comment } from 'common/comment'
export const convertComment = (row: Row<'lover_comments'>): Comment => ({
export const convertComment = (row: Row<'profile_comments'>): Comment => ({
id: row.id + '',
userId: row.user_id,
commentType: 'lover',
commentType: 'profile',
onUserId: row.on_user_id,
createdTime: tsToMillis(row.created_time),
userName: row.user_name,

View File

@@ -7,8 +7,43 @@ export type Json =
| Json[]
export type Database = {
// Allows to automatically instantiate createClient with right options
// instead of createClient<Database, { PostgrestVersion: 'XX' }>(URL, KEY)
__InternalSupabase: {
PostgrestVersion: '13.0.4'
}
public: {
Tables: {
bookmarked_searches: {
Row: {
created_time: string
creator_id: string
id: number
last_notified_at: string | null
location: Json | null
search_filters: Json | null
search_name: string | null
}
Insert: {
created_time?: string
creator_id: string
id?: never
last_notified_at?: string | null
location?: Json | null
search_filters?: Json | null
search_name?: string | null
}
Update: {
created_time?: string
creator_id?: string
id?: never
last_notified_at?: string | null
location?: Json | null
search_filters?: Json | null
search_name?: string | null
}
Relationships: []
}
love_answers: {
Row: {
created_time: string
@@ -186,7 +221,7 @@ export type Database = {
}
Relationships: []
}
lover_comments: {
profile_comments: {
Row: {
content: Json
created_time: string
@@ -225,141 +260,6 @@ export type Database = {
}
Relationships: []
}
lovers: {
Row: {
age: number
bio: Json | null
born_in_location: string | null
city: string
city_latitude: number | null
city_longitude: number | null
comments_enabled: boolean
company: string | null
country: string | null
created_time: string
drinks_per_month: number | null
education_level: string | null
ethnicity: string[] | null
gender: string
geodb_city_id: string | null
has_kids: number | null
height_in_inches: number | null
id: number
is_smoker: boolean | null
is_vegetarian_or_vegan: boolean | null
last_online_time: string
looking_for_matches: boolean
messaging_status: string
occupation: string | null
occupation_title: string | null
photo_urls: string[] | null
pinned_url: string | null
political_beliefs: string[] | null
pref_age_max: number
pref_age_min: number
pref_gender: string[]
pref_relation_styles: string[]
referred_by_username: string | null
region_code: string | null
religious_belief_strength: number | null
religious_beliefs: string | null
twitter: string | null
university: string | null
user_id: string
visibility: Database['public']['Enums']['lover_visibility']
wants_kids_strength: number
website: string | null
}
Insert: {
age?: number
bio?: Json | null
born_in_location?: string | null
city: string
city_latitude?: number | null
city_longitude?: number | null
comments_enabled?: boolean
company?: string | null
country?: string | null
created_time?: string
drinks_per_month?: number | null
education_level?: string | null
ethnicity?: string[] | null
gender: string
geodb_city_id?: string | null
has_kids?: number | null
height_in_inches?: number | null
id?: never
is_smoker?: boolean | null
is_vegetarian_or_vegan?: boolean | null
last_online_time?: string
looking_for_matches?: boolean
messaging_status?: string
occupation?: string | null
occupation_title?: string | null
photo_urls?: string[] | null
pinned_url?: string | null
political_beliefs?: string[] | null
pref_age_max?: number
pref_age_min?: number
pref_gender: string[]
pref_relation_styles: string[]
referred_by_username?: string | null
region_code?: string | null
religious_belief_strength?: number | null
religious_beliefs?: string | null
twitter?: string | null
university?: string | null
user_id: string
visibility?: Database['public']['Enums']['lover_visibility']
wants_kids_strength?: number
website?: string | null
}
Update: {
age?: number
bio?: Json | null
born_in_location?: string | null
city?: string
city_latitude?: number | null
city_longitude?: number | null
comments_enabled?: boolean
company?: string | null
country?: string | null
created_time?: string
drinks_per_month?: number | null
education_level?: string | null
ethnicity?: string[] | null
gender?: string
geodb_city_id?: string | null
has_kids?: number | null
height_in_inches?: number | null
id?: never
is_smoker?: boolean | null
is_vegetarian_or_vegan?: boolean | null
last_online_time?: string
looking_for_matches?: boolean
messaging_status?: string
occupation?: string | null
occupation_title?: string | null
photo_urls?: string[] | null
pinned_url?: string | null
political_beliefs?: string[] | null
pref_age_max?: number
pref_age_min?: number
pref_gender?: string[]
pref_relation_styles?: string[]
referred_by_username?: string | null
region_code?: string | null
religious_belief_strength?: number | null
religious_beliefs?: string | null
twitter?: string | null
university?: string | null
user_id?: string
visibility?: Database['public']['Enums']['lover_visibility']
wants_kids_strength?: number
website?: string | null
}
Relationships: []
}
private_user_message_channel_members: {
Row: {
channel_id: number
@@ -407,13 +307,13 @@ export type Database = {
}
Insert: {
created_time?: string
id?: never
id?: number
last_updated_time?: string
title?: string | null
}
Update: {
created_time?: string
id?: never
id?: number
last_updated_time?: string
title?: string | null
}
@@ -501,6 +401,144 @@ export type Database = {
}
Relationships: []
}
profiles: {
Row: {
age: number | null
bio: Json | null
born_in_location: string | null
city: string
city_latitude: number | null
city_longitude: number | null
comments_enabled: boolean
company: string | null
country: string | null
created_time: string
drinks_per_month: number | null
education_level: string | null
ethnicity: string[] | null
gender: string
geodb_city_id: string | null
has_kids: number | null
height_in_inches: number | null
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
occupation_title: string | null
photo_urls: string[] | null
pinned_url: string | null
political_beliefs: string[] | null
pref_age_max: number | null
pref_age_min: number | null
pref_gender: string[]
pref_relation_styles: string[]
referred_by_username: string | null
region_code: string | null
religious_belief_strength: number | null
religious_beliefs: string | null
twitter: string | null
university: string | null
user_id: string
visibility: Database['public']['Enums']['profile_visibility']
wants_kids_strength: number
website: string | null
}
Insert: {
age?: number | null
bio?: Json | null
born_in_location?: string | null
city: string
city_latitude?: number | null
city_longitude?: number | null
comments_enabled?: boolean
company?: string | null
country?: string | null
created_time?: string
drinks_per_month?: number | null
education_level?: string | null
ethnicity?: string[] | null
gender: string
geodb_city_id?: string | null
has_kids?: number | null
height_in_inches?: number | null
id?: never
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
occupation_title?: string | null
photo_urls?: string[] | null
pinned_url?: string | null
political_beliefs?: string[] | null
pref_age_max?: number | null
pref_age_min?: number | null
pref_gender: string[]
pref_relation_styles: string[]
referred_by_username?: string | null
region_code?: string | null
religious_belief_strength?: number | null
religious_beliefs?: string | null
twitter?: string | null
university?: string | null
user_id: string
visibility?: Database['public']['Enums']['profile_visibility']
wants_kids_strength?: number
website?: string | null
}
Update: {
age?: number | null
bio?: Json | null
born_in_location?: string | null
city?: string
city_latitude?: number | null
city_longitude?: number | null
comments_enabled?: boolean
company?: string | null
country?: string | null
created_time?: string
drinks_per_month?: number | null
education_level?: string | null
ethnicity?: string[] | null
gender?: string
geodb_city_id?: string | null
has_kids?: number | null
height_in_inches?: number | null
id?: never
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
occupation_title?: string | null
photo_urls?: string[] | null
pinned_url?: string | null
political_beliefs?: string[] | null
pref_age_max?: number | null
pref_age_min?: number | null
pref_gender?: string[]
pref_relation_styles?: string[]
referred_by_username?: string | null
region_code?: string | null
religious_belief_strength?: number | null
religious_beliefs?: string | null
twitter?: string | null
university?: string | null
user_id?: string
visibility?: Database['public']['Enums']['profile_visibility']
wants_kids_strength?: number
website?: string | null
}
Relationships: []
}
reports: {
Row: {
content_id: string
@@ -552,33 +590,6 @@ export type Database = {
}
]
}
temp_users: {
Row: {
created_time: string | null
id: string | null
name: string | null
private_user_data: Json | null
user_data: Json | null
username: string | null
}
Insert: {
created_time?: string | null
id?: string | null
name?: string | null
private_user_data?: Json | null
user_data?: Json | null
username?: string | null
}
Update: {
created_time?: string | null
id?: string | null
name?: string | null
private_user_data?: Json | null
user_data?: Json | null
username?: string | null
}
Relationships: []
}
user_events: {
Row: {
ad_id: string | null
@@ -663,7 +674,7 @@ export type Database = {
}
Functions: {
calculate_earth_distance_km: {
Args: { lat1: number; lon1: number; lat2: number; lon2: number }
Args: { lat1: number; lat2: number; lon1: number; lon2: number }
Returns: number
}
can_access_private_messages: {
@@ -682,16 +693,36 @@ export type Database = {
Args: Record<PropertyKey, never>
Returns: Record<string, unknown>[]
}
get_love_question_answers_and_lovers: {
get_love_question_answers_and_profiles: {
Args: { p_question_id: number }
Returns: Record<string, unknown>[]
}
gtrgm_compress: {
Args: { '': unknown }
Returns: unknown
}
gtrgm_decompress: {
Args: { '': unknown }
Returns: unknown
}
gtrgm_in: {
Args: { '': unknown }
Returns: unknown
}
gtrgm_options: {
Args: { '': unknown }
Returns: undefined
}
gtrgm_out: {
Args: { '': unknown }
Returns: unknown
}
is_admin: {
Args: Record<PropertyKey, never> | { user_id: string }
Returns: boolean
}
millis_interval: {
Args: { start_millis: number; end_millis: number }
Args: { end_millis: number; start_millis: number }
Returns: unknown
}
millis_to_ts: {
@@ -702,17 +733,29 @@ export type Database = {
Args: { length: number }
Returns: string
}
set_limit: {
Args: { '': number }
Returns: number
}
show_limit: {
Args: Record<PropertyKey, never>
Returns: number
}
show_trgm: {
Args: { '': string }
Returns: string[]
}
to_jsonb: {
Args: { '': Json }
Returns: Json
}
ts_to_millis: {
Args: { ts: string } | { ts: string }
Args: { ts: string }
Returns: number
}
}
Enums: {
lover_visibility: 'public' | 'member'
profile_visibility: 'public' | 'member'
}
CompositeTypes: {
[_ in never]: never
@@ -720,21 +763,25 @@ export type Database = {
}
}
type DefaultSchema = Database[Extract<keyof Database, 'public'>]
type DatabaseWithoutInternals = Omit<Database, '__InternalSupabase'>
type DefaultSchema = DatabaseWithoutInternals[Extract<keyof Database, 'public'>]
export type Tables<
DefaultSchemaTableNameOrOptions extends
| keyof (DefaultSchema['Tables'] & DefaultSchema['Views'])
| { schema: keyof Database },
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof Database
schema: keyof DatabaseWithoutInternals
}
? keyof (Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'] &
Database[DefaultSchemaTableNameOrOptions['schema']]['Views'])
? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Views'])
: never = never
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
? (Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'] &
Database[DefaultSchemaTableNameOrOptions['schema']]['Views'])[TableName] extends {
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'] &
DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Views'])[TableName] extends {
Row: infer R
}
? R
@@ -752,14 +799,16 @@ export type Tables<
export type TablesInsert<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema['Tables']
| { schema: keyof Database },
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof Database
schema: keyof DatabaseWithoutInternals
}
? keyof Database[DefaultSchemaTableNameOrOptions['schema']]['Tables']
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables']
: never = never
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
? Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends {
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends {
Insert: infer I
}
? I
@@ -775,14 +824,16 @@ export type TablesInsert<
export type TablesUpdate<
DefaultSchemaTableNameOrOptions extends
| keyof DefaultSchema['Tables']
| { schema: keyof Database },
| { schema: keyof DatabaseWithoutInternals },
TableName extends DefaultSchemaTableNameOrOptions extends {
schema: keyof Database
schema: keyof DatabaseWithoutInternals
}
? keyof Database[DefaultSchemaTableNameOrOptions['schema']]['Tables']
? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables']
: never = never
> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database }
? Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends {
> = DefaultSchemaTableNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends {
Update: infer U
}
? U
@@ -798,14 +849,16 @@ export type TablesUpdate<
export type Enums<
DefaultSchemaEnumNameOrOptions extends
| keyof DefaultSchema['Enums']
| { schema: keyof Database },
| { schema: keyof DatabaseWithoutInternals },
EnumName extends DefaultSchemaEnumNameOrOptions extends {
schema: keyof Database
schema: keyof DatabaseWithoutInternals
}
? keyof Database[DefaultSchemaEnumNameOrOptions['schema']]['Enums']
? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions['schema']]['Enums']
: never = never
> = DefaultSchemaEnumNameOrOptions extends { schema: keyof Database }
? Database[DefaultSchemaEnumNameOrOptions['schema']]['Enums'][EnumName]
> = DefaultSchemaEnumNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions['schema']]['Enums'][EnumName]
: DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema['Enums']
? DefaultSchema['Enums'][DefaultSchemaEnumNameOrOptions]
: never
@@ -813,14 +866,16 @@ export type Enums<
export type CompositeTypes<
PublicCompositeTypeNameOrOptions extends
| keyof DefaultSchema['CompositeTypes']
| { schema: keyof Database },
| { schema: keyof DatabaseWithoutInternals },
CompositeTypeName extends PublicCompositeTypeNameOrOptions extends {
schema: keyof Database
schema: keyof DatabaseWithoutInternals
}
? keyof Database[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes']
? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes']
: never = never
> = PublicCompositeTypeNameOrOptions extends { schema: keyof Database }
? Database[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes'][CompositeTypeName]
> = PublicCompositeTypeNameOrOptions extends {
schema: keyof DatabaseWithoutInternals
}
? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes'][CompositeTypeName]
: PublicCompositeTypeNameOrOptions extends keyof DefaultSchema['CompositeTypes']
? DefaultSchema['CompositeTypes'][PublicCompositeTypeNameOrOptions]
: never
@@ -828,7 +883,7 @@ export type CompositeTypes<
export const Constants = {
public: {
Enums: {
lover_visibility: ['public', 'member'],
profile_visibility: ['public', 'member'],
},
},
} as const

View File

@@ -8,6 +8,7 @@ export type notification_preferences = {
new_endorsement: notification_destination_types[]
new_love_like: notification_destination_types[]
new_love_ship: notification_destination_types[]
new_search_alerts: notification_destination_types[]
// User-related
new_message: notification_destination_types[]
@@ -37,6 +38,7 @@ export const getDefaultNotificationPreferences = (isDev?: boolean) => {
}
const defaults: notification_preferences = {
new_match: constructPref(true, true, true),
new_search_alerts: constructPref(true, true, true),
new_endorsement: constructPref(true, true, true),
new_love_like: constructPref(true, false, false),
new_love_ship: constructPref(true, false, false),
@@ -59,8 +61,10 @@ export const getNotificationDestinationsForUser = (
privateUser: PrivateUser,
type: notification_preference
) => {
const destinations = privateUser.notificationPreferences[type]
const opt_out = privateUser.notificationPreferences.opt_out_all
let destinations = privateUser.notificationPreferences[type]
if (!destinations) destinations = ['email', 'browser', 'mobile']
let opt_out = privateUser.notificationPreferences.opt_out_all
if (!opt_out) opt_out = []
return {
sendToEmail: destinations.includes('email') && !opt_out.includes('email'),

View File

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

View File

@@ -1,4 +1,4 @@
import { DOMAIN } from 'common/envs/constants'
import {DOMAIN, LOCAL_DEV, LOCAL_WEB_URL} from 'common/envs/constants'
// opengraph functions that run in static props or client-side, but not in the edge (in image creation)
@@ -10,10 +10,5 @@ export function buildOgUrl<P extends Record<string, string>>(
const generateUrlParams = (params: P) =>
new URLSearchParams(params).toString()
// Change to localhost:3000 for local testing
const url =
// `http://localhost:3000/api/og/${endpoint}?` + generateUrlParams(props)
`https://${domain ?? DOMAIN}/api/og/${endpoint}?` + generateUrlParams(props)
return url
return `https://${domain ?? LOCAL_DEV ? LOCAL_WEB_URL : DOMAIN}/api/og/${endpoint}?` + generateUrlParams(props)
}

68
common/src/wants-kids.ts Normal file
View File

@@ -0,0 +1,68 @@
import {hasKidsLabels} from "common/has-kids";
export type KidLabel = {
name: string
shortName: string
strength: number
}
export type KidsLabelsMap = Record<string, KidLabel>
export const wantsKidsLabels: KidsLabelsMap = {
no_preference: {
name: 'Any preference',
shortName: 'Either',
strength: -1,
},
wants_kids: {
name: 'Wants kids',
shortName: 'Yes',
strength: 2,
},
doesnt_want_kids: {
name: `Doesn't want kids`,
shortName: 'No',
strength: 0,
},
}
export const wantsKidsNames = Object.values(wantsKidsLabels).reduce<Record<number, string>>(
(acc, {strength, name}) => {
acc[strength] = name
return acc
},
{}
)
export type wantsKidsDatabase = 0 | 1 | 2 | 3 | 4
export function wantsKidsToHasKidsFilter(wantsKidsStrength: wantsKidsDatabase) {
if (wantsKidsStrength < wantsKidsLabels.wants_kids.strength) {
return hasKidsLabels.doesnt_have_kids.value
}
return hasKidsLabels.no_preference.value
}
export function wantsKidsDatabaseToWantsKidsFilter(
wantsKidsStrength: wantsKidsDatabase
) {
// console.log(wantsKidsStrength)
if (wantsKidsStrength == wantsKidsLabels.no_preference.strength) {
return wantsKidsLabels.no_preference.strength
}
if (wantsKidsStrength > wantsKidsLabels.wants_kids.strength) {
return wantsKidsLabels.wants_kids.strength
}
if (wantsKidsStrength < wantsKidsLabels.wants_kids.strength) {
return wantsKidsLabels.doesnt_want_kids.strength
}
return wantsKidsLabels.no_preference.strength
}
export const generateChoicesMap = (labels: KidsLabelsMap): Record<string, number> => {
return Object.values(labels).reduce(
(acc: Record<string, number>, label: KidLabel) => {
acc[label.shortName] = label.strength
return acc
},
{}
)
}

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