203 Commits
1.1.0 ... 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
MartinBraquet
277c6a444f Release 2025-09-12 01:40:58 +02:00
MartinBraquet
f344800fd6 Fix yarn install warnings 2025-09-12 01:38:48 +02:00
MartinBraquet
39a6fba33f Remove unused and confusing sub lock files 2025-09-12 01:30:50 +02:00
MartinBraquet
8e11657bd2 Update install 2025-09-12 01:26:49 +02:00
MartinBraquet
dfbeaa4edf Clean lock 2025-09-12 01:23:16 +02:00
MartinBraquet
e90dc3b7f4 Remove log 2025-09-12 01:23:06 +02:00
MartinBraquet
dba89e611a Fix package backend 2025-09-12 01:17:39 +02:00
MartinBraquet
1a3fecc89e Fix 2025-09-12 00:46:38 +02:00
MartinBraquet
407e6a3d06 Fix yarn.lock 2025-09-12 00:40:46 +02:00
MartinBraquet
6ee19d5359 Back to working on vercel 2025-09-12 00:31:11 +02:00
MartinBraquet
2df424dbac Remove log 2025-09-12 00:14:23 +02:00
MartinBraquet
9874be6bf1 Fix packages 2025-09-12 00:10:50 +02:00
MartinBraquet
a3d4199d1d Fix vercel build 2025-09-11 23:56:41 +02:00
MartinBraquet
247fa146a9 Fix 2025-09-11 23:26:38 +02:00
MartinBraquet
f2b2c02cd6 Add install.sh 2025-09-11 22:56:29 +02:00
MartinBraquet
a915f27f00 Roolback 2025-09-11 22:56:21 +02:00
MartinBraquet
e14a488934 Revert "Failed attempt to use react icons in emails"
This reverts commit e82a8d9bc3.
2025-09-11 22:35:54 +02:00
MartinBraquet
e82a8d9bc3 Failed attempt to use react icons in emails 2025-09-11 22:35:48 +02:00
MartinBraquet
4527a0d12b Rm add tsconfig 2025-09-11 22:34:00 +02:00
MartinBraquet
01be202484 Cd cur dir 2025-09-11 19:20:40 +02:00
MartinBraquet
d1fe99edc3 Remove log 2025-09-11 19:20:32 +02:00
MartinBraquet
fa629591e9 Fix unsubscribe URL 2025-09-11 18:45:42 +02:00
MartinBraquet
4ab3edc97b Add email footer 2025-09-11 18:37:31 +02:00
MartinBraquet
f1bfc6bf55 Fix connection type (2) 2025-09-11 16:51:13 +02:00
MartinBraquet
3283843ef3 Fix connection type 2025-09-11 16:21:01 +02:00
MartinBraquet
4cb14ec8cc Fix gender 2025-09-11 16:20:11 +02:00
MartinBraquet
41535a68be Improve email UI 2025-09-11 16:00:10 +02:00
MartinBraquet
d62447a12a Unstick like 2025-09-11 14:48:36 +02:00
MartinBraquet
802367c914 Search users 2025-09-11 14:48:24 +02:00
MartinBraquet
ff9b2c6ee8 Add compatibility score FAQ 2025-09-11 14:12:44 +02:00
MartinBraquet
a0e25c941a Smoot login 2025-09-11 13:59:23 +02:00
MartinBraquet
091c99e784 Reduce sidebar width 2025-09-11 13:27:44 +02:00
MartinBraquet
e264bb407b Improve sign in / up UI 2025-09-11 13:14:20 +02:00
MartinBraquet
16625210fc Fix 2025-09-11 12:51:37 +02:00
MartinBraquet
2550453ee4 Add keyword search 2025-09-10 22:50:20 +02:00
MartinBraquet
d1c480f23f Change genders 2025-09-10 21:54:03 +02:00
MartinBraquet
b4b0397589 Hide gender they are interested in 2025-09-10 21:53:55 +02:00
MartinBraquet
ab6b34e84c Hide supabase annon key (env) 2025-09-10 21:41:08 +02:00
MartinBraquet
87af9d5078 Hide supabase annon key 2025-09-10 21:40:31 +02:00
MartinBraquet
95fab7c395 Add reports table 2025-09-10 21:40:23 +02:00
MartinBraquet
90825925ff Improve share button layout 2025-09-10 21:39:57 +02:00
MartinBraquet
7036cf9e49 Profile view when signed up: no pic, see first lines of bio 2025-09-10 20:42:09 +02:00
MartinBraquet
53123eb0ee Improve UI 2025-09-10 18:24:44 +02:00
MartinBraquet
3c5407dd51 Fix colors 2025-09-10 17:09:35 +02:00
MartinBraquet
1ffe81f740 Fix link 2025-09-10 16:20:50 +02:00
MartinBraquet
6bb35d61e1 Clean 2025-09-10 16:16:11 +02:00
MartinBraquet
f36ccf7bdc Fix colors 2025-09-10 16:16:08 +02:00
MartinBraquet
4632e68a00 Add bookish font 2025-09-10 16:14:56 +02:00
MartinBraquet
09858d0783 Change md path 2025-09-10 16:14:33 +02:00
MartinBraquet
9d1423c41b Add todo 2025-09-10 14:29:11 +02:00
MartinBraquet
1a4b7786dd Fix blue links 2025-09-10 12:53:34 +02:00
MartinBraquet
77c0a21ad0 Add info about Martin 2025-09-10 12:23:33 +02:00
MartinBraquet
7cedf14121 Add members page 2025-09-10 12:23:25 +02:00
MartinBraquet
235346f3dd Add financials link 2025-09-10 11:26:14 +02:00
MartinBraquet
34c36b7c3a Fix 2025-09-09 19:24:08 +02:00
MartinBraquet
3e0f788ec3 Add FAQ and financials 2025-09-09 19:18:44 +02:00
MartinBraquet
867bb8a072 Fix 2025-09-09 18:55:47 +02:00
MartinBraquet
31a400158a Do not render your own profile in Profiles 2025-09-09 16:58:07 +02:00
MartinBraquet
8106ff6489 Show compatibility score of no preferred gender 2025-09-09 16:57:02 +02:00
MartinBraquet
de3508993c Factor out geodbFetch 2025-09-09 16:07:36 +02:00
MartinBraquet
fd3e7a6f8a Fix location filtering 2025-09-09 15:55:33 +02:00
MartinBraquet
4cf97a6054 Fix 2025-09-09 15:25:21 +02:00
MartinBraquet
75036e3ec7 Fix 2025-09-09 14:57:08 +02:00
252 changed files with 5757 additions and 17127 deletions

View File

@@ -1,13 +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=
# 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.

View File

@@ -46,10 +46,12 @@ jobs:
# npx playwright install
- name: Run E2E tests
env:
NEXT_PUBLIC_API_URL: localhost:8088
NEXT_PUBLIC_FIREBASE_ENV: PROD
NEXT_PUBLIC_FIREBASE_API_KEY: ${{ secrets.NEXT_PUBLIC_FIREBASE_API_KEY }}
NEXT_PUBLIC_SUPABASE_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_KEY }}
run: |
NEXT_PUBLIC_API_URL=localhost:8088 \
NEXT_PUBLIC_FIREBASE_ENV=PROD \
NEXT_PUBLIC_FIREBASE_API_KEY=${{ secrets.NEXT_PUBLIC_FIREBASE_API_KEY }} \
yarn --cwd=web serve &
npx wait-on http://localhost:3000
npx playwright test tests/playwright

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

126
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)
@@ -29,20 +34,37 @@ A detailed description of the vision is available [here](https://martinbraquet.c
- [x] Ask for detailed info upon registration (location, desired type of connection, prompt answers, gender, etc.)
- [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 chat / direct messaging
- [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)
- [ ] Clean up terms and conditions
- [ ] Clean up privacy notice
- [x] Clean up learn more page
- [x] Add dark theme
- [ ] Make the app more user-friendly and appealing (UI/UX)
- [ ] Clean up terms and conditions (convert to Markdown)
- [ ] Clean up privacy notice (convert to Markdown)
- [ ] Add other authentication methods (GitHub, Facebook, Apple, phone, etc.)
- [ ] Add email verification
- [ ] Add password reset
- [ ] Add automated welcome email
- [ ] 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
@@ -55,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
```
@@ -69,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
@@ -83,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
@@ -139,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

@@ -11,6 +11,8 @@
set -e
cd "$(dirname "$0")"
source ../../.env
ENV=${1:-prod}
@@ -28,7 +30,6 @@ IMAGE_TAG="${TIMESTAMP}-${GIT_REVISION}"
IMAGE_URL="${REGION}-docker.pkg.dev/${PROJECT}/builds/${SERVICE_NAME}:${IMAGE_TAG}"
echo "🚀 Deploying ${SERVICE_NAME} to ${ENV} ($(date "+%Y-%m-%d %I:%M:%S %p"))"
yarn add tsconfig-paths
yarn build
gcloud auth print-access-token | docker login -u oauth2accesstoken --password-stdin us-west1-docker.pkg.dev

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

@@ -1,7 +1,7 @@
{
"name": "@compass/api",
"description": "Backend API endpoints",
"version": "0.1.0",
"version": "1.0.0",
"private": true,
"scripts": {
"watch:compile": "npx concurrently \"tsc -b --watch --preserveWatchOutput\" \"(cd ../../common && tsc-alias --watch)\" \"(cd ../shared && tsc-alias --watch)\" \"(cd ../email && tsc-alias --watch)\" \"tsc-alias --watch\"",
@@ -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,24 +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": "19.0.0",
"react-dom": "19.0.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/react": "18.3.5",
"@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)) || '%'`, { 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

@@ -1,36 +1,8 @@
import { APIHandler } from './helpers/endpoint'
import {APIHandler} from './helpers/endpoint'
import {geodbFetch} from "common/geodb";
export const searchLocation: APIHandler<'search-location'> = async (body) => {
const { term, limit } = body
const apiKey = process.env.GEODB_API_KEY
console.log('GEODB_API_KEY', apiKey)
if (!apiKey) {
return { status: 'failure', data: 'Missing GEODB API key' }
}
const host = 'wft-geo-db.p.rapidapi.com'
const baseUrl = `https://${host}/v1/geo`
const url = `${baseUrl}/cities?namePrefix=${term}&limit=${
limit ?? 10
}&offset=0&sort=-population`
try {
const res = await fetch(url, {
method: 'GET',
headers: {
'X-RapidAPI-Key': apiKey,
'X-RapidAPI-Host': host,
},
})
if (!res.ok) {
throw new Error(`HTTP error! Status: ${res.status} ${await res.text()}`)
}
const data = await res.json()
// console.log('GEO DB', data)
return { status: 'success', data: data }
} catch (error: any) {
console.log('failure', error)
return { status: 'failure', data: error.message }
}
const {term, limit} = body
const endpoint = `/cities?namePrefix=${term}&limit=${limit ?? 10}&offset=0&sort=-population`
return await geodbFetch(endpoint)
}

View File

@@ -1,41 +1,17 @@
import { APIHandler } from './helpers/endpoint'
import {APIHandler} from './helpers/endpoint'
import {geodbFetch} from "common/geodb";
const searchNearCityMain = async (cityId: string, radius: number) => {
// Limit to 10 cities for now for free plan, was 100 before (may need to buy plan)
const endpoint = `/cities/${cityId}/nearbyCities?radius=${radius}&offset=0&sort=-population&limit=10`
return await geodbFetch(endpoint)
}
export const searchNearCity: APIHandler<'search-near-city'> = async (body) => {
const { cityId, radius } = body
return await searchNearCityMain(cityId, radius)
}
const searchNearCityMain = async (cityId: string, radius: number) => {
const apiKey = process.env.GEODB_API_KEY
if (!apiKey) {
return { status: 'failure', data: 'Missing GEODB API key' }
}
const host = 'wft-geo-db.p.rapidapi.com'
const baseUrl = `https://${host}/v1/geo`
const url = `${baseUrl}/cities/${cityId}/nearbyCities?radius=${radius}&offset=0&sort=-population&limit=100`
try {
const res = await fetch(url, {
method: 'GET',
headers: {
'X-RapidAPI-Key': apiKey,
'X-RapidAPI-Host': host,
},
})
if (!res.ok) {
throw new Error(`HTTP error! Status: ${res.status}`)
}
const data = await res.json()
return { status: 'success', data: data }
} catch (error) {
return { status: 'failure', data: error }
}
}
export const getNearbyCities = async (cityId: string, radius: number) => {
const result = await searchNearCityMain(cityId, radius)
const cityIds = (result.data.data as any[]).map(

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,41 +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}
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,
@@ -43,45 +45,58 @@ 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({
from,
subject: `${fromUser.name} sent you a message!`,
to: privateUser.email,
html: renderToStaticMarkup(
<NewMessageEmail
fromUser={fromUser}
fromUserLover={lover}
toUser={toUser}
channelId={channelId}
unsubscribeUrl={unsubscribeUrl}
/>
),
})
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}
email={privateUser.email}
/>
),
})
}
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: `People aligned with your values just joined`,
to: email,
html: await render(
<NewSearchAlertsEmail
toUser={toUser}
matches={matches}
unsubscribeUrl={unsubscribeUrl}
email={email}
/>
),
})
@@ -93,7 +108,7 @@ export const sendNewEndorsementEmail = async (
onUser: User,
text: string
) => {
const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
const {sendToEmail, unsubscribeUrl} = getNotificationDestinationsForUser(
privateUser,
'new_endorsement'
)
@@ -103,12 +118,13 @@ export const sendNewEndorsementEmail = async (
from,
subject: `${fromUser.name} just endorsed you!`,
to: privateUser.email,
react: (
html: await render(
<NewEndorsementEmail
fromUser={fromUser}
onUser={onUser}
endorsementText={text}
unsubscribeUrl={unsubscribeUrl}
email={privateUser.email}
/>
),
})
@@ -119,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

@@ -37,7 +37,7 @@ const getResend = () => {
if (resend) return resend
const apiKey = process.env.RESEND_KEY as string
console.log(`RESEND_KEY: ${apiKey}`)
// console.log(`RESEND_KEY: ${apiKey}`)
resend = new Resend(apiKey)
return resend
}

View File

@@ -1,41 +1,31 @@
import {
Body,
Button,
Container,
Column,
Head,
Html,
Img,
Link,
Preview,
Row,
Section,
Text,
} from '@react-email/components'
import { type User } from 'common/user'
import { DOMAIN } from 'common/envs/constants'
import { jamesUser, sinclairUser } from './functions/mock'
import {Body, Button, Column, Container, Head, Html, Preview, Row, Section, Text,} from '@react-email/components'
import {type User} from 'common/user'
import {DOMAIN} from 'common/envs/constants'
import {jamesUser, sinclairUser} from './functions/mock'
import {button, container, content, Footer, main, paragraph} from "email/utils";
interface NewEndorsementEmailProps {
fromUser: User
onUser: User
endorsementText: string
unsubscribeUrl: string
email?: string
}
export const NewEndorsementEmail = ({
fromUser,
onUser,
endorsementText,
unsubscribeUrl,
}: NewEndorsementEmailProps) => {
fromUser,
onUser,
endorsementText,
unsubscribeUrl,
email,
}: NewEndorsementEmailProps) => {
const name = onUser.name.split(' ')[0]
const endorsementUrl = `https://${DOMAIN}/${onUser.username}`
return (
<Html>
<Head />
<Head/>
<Preview>New endorsement from {fromUser.name}</Preview>
<Body style={main}>
<Container style={container}>
@@ -55,15 +45,15 @@ export const NewEndorsementEmail = ({
<Section style={endorsementContainer}>
<Row>
<Column>
<Img
src={fromUser.avatarUrl}
width="50"
height="50"
alt=""
style={avatarImage}
/>
</Column>
{/*<Column>*/}
{/* <Img*/}
{/* src={fromUser.avatarUrl}*/}
{/* width="50"*/}
{/* height="50"*/}
{/* alt=""*/}
{/* style={avatarImage}*/}
{/* />*/}
{/*</Column>*/}
<Column>
<Text style={endorsementTextStyle}>"{endorsementText}"</Text>
</Column>
@@ -75,15 +65,7 @@ export const NewEndorsementEmail = ({
</Section>
</Section>
<Section style={footer}>
<Text style={footerText}>
This e-mail has been sent to {name},{' '}
{/* <Link href={unsubscribeUrl} style={footerLink}>
click here to unsubscribe from this type of notification
</Link>
. */}
</Text>
</Section>
<Footer unsubscribeUrl={unsubscribeUrl} email={email ?? name}/>
</Container>
</Body>
</Html>
@@ -96,37 +78,9 @@ NewEndorsementEmail.PreviewProps = {
endorsementText:
"Sinclair is someone you want to have around because she injects creativity and humor into every conversation, and her laugh is infectious! Not to mention that she's a great employee, treats everyone with respect, and is even-tempered.",
unsubscribeUrl: 'https://compassmeet.com/unsubscribe',
email: 'someone@gmail.com',
} as NewEndorsementEmailProps
const main = {
backgroundColor: '#f4f4f4',
fontFamily: 'Arial, sans-serif',
wordSpacing: 'normal',
}
const container = {
margin: '0 auto',
maxWidth: '600px',
}
const logoContainer = {
padding: '20px 0px 5px 0px',
textAlign: 'center' as const,
backgroundColor: '#ffffff',
}
const content = {
backgroundColor: '#ffffff',
padding: '20px 25px',
}
const paragraph = {
fontSize: '18px',
lineHeight: '24px',
margin: '10px 0',
color: '#000000',
fontFamily: 'Arial, Helvetica, sans-serif',
}
const endorsementContainer = {
margin: '20px 0',
@@ -135,10 +89,6 @@ const endorsementContainer = {
borderRadius: '8px',
}
const avatarImage = {
borderRadius: '50%',
}
const endorsementTextStyle = {
fontSize: '16px',
lineHeight: '22px',
@@ -146,35 +96,4 @@ const endorsementTextStyle = {
color: '#333333',
}
const button = {
backgroundColor: '#4887ec',
borderRadius: '12px',
color: '#ffffff',
fontFamily: 'Helvetica, Arial, sans-serif',
fontSize: '16px',
fontWeight: 'semibold',
textDecoration: 'none',
textAlign: 'center' as const,
display: 'inline-block',
padding: '6px 10px',
margin: '10px 0',
}
const footer = {
margin: '20px 0',
textAlign: 'center' as const,
}
const footerText = {
fontSize: '11px',
lineHeight: '22px',
color: '#000000',
fontFamily: 'Ubuntu, Helvetica, Arial, sans-serif',
}
const footerLink = {
color: 'inherit',
textDecoration: 'none',
}
export default NewEndorsementEmail

View File

@@ -1,51 +1,43 @@
import {
Body,
Button,
Container,
Head,
Html,
Img,
Link,
Preview,
Section,
Text,
} from '@react-email/components'
import { DOMAIN } from 'common/envs/constants'
import { type LoverRow } from 'common/love/lover'
import { getLoveOgImageUrl } from 'common/love/og-image'
import { type User } from 'common/user'
import { jamesLover, jamesUser, sinclairUser } from './functions/mock'
import {Body, Button, Container, Head, Html, Preview, Section, Text,} from '@react-email/components'
import {DOMAIN} from 'common/envs/constants'
import {type ProfileRow} from 'common/love/profile'
import {type User} from 'common/user'
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
}
export const NewMatchEmail = ({
onUser,
matchedWithUser,
matchedLover,
unsubscribeUrl,
}: NewMatchEmailProps) => {
onUser,
matchedWithUser,
// 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 (
<Html>
<Head />
<Head/>
<Preview>You have a new match!</Preview>
<Body style={main}>
<Container style={container}>
{/*<Section style={logoContainer}>*/}
{/* <Img*/}
{/* src="..."*/}
{/* width="550"*/}
{/* height="auto"*/}
{/* alt="compassmeet.com"*/}
{/* />*/}
{/*<Img*/}
{/* src="..."*/}
{/* width="550"*/}
{/* height="auto"*/}
{/* alt="compassmeet.com"*/}
{/*/>*/}
{/*</Section>*/}
<Section style={content}>
@@ -56,31 +48,21 @@ export const NewMatchEmail = ({
</Text>
<Section style={imageContainer}>
<Link href={userUrl}>
<Img
src={userImgSrc}
width="375"
height="200"
alt=""
style={profileImage}
/>
</Link>
{/*<Link href={userUrl}>*/}
{/* <Img*/}
{/* src={userImgSrc}*/}
{/* width="375"*/}
{/* height="200"*/}
{/* alt=""*/}
{/* style={profileImage}*/}
{/* />*/}
{/*</Link>*/}
<Button href={userUrl} style={button}>
View profile
</Button>
</Section>
</Section>
<Section style={footer}>
<Text style={footerText}>
This e-mail has been sent to {name},{' '}
{/* <Link href={unsubscribeUrl} style={footerLink}>
click here to unsubscribe from this type of notification
</Link>
. */}
</Text>
</Section>
<Footer unsubscribeUrl={unsubscribeUrl} email={email ?? name}/>
</Container>
</Body>
</Html>
@@ -90,12 +72,13 @@ export const NewMatchEmail = ({
NewMatchEmail.PreviewProps = {
onUser: sinclairUser,
matchedWithUser: jamesUser,
matchedLover: jamesLover,
matchedProfile: jamesProfile,
email: 'someone@gmail.com',
unsubscribeUrl: 'https://compassmeet.com/unsubscribe',
} as NewMatchEmailProps
const main = {
backgroundColor: '#f4f4f4',
// backgroundColor: '#f4f4f4',
fontFamily: 'Arial, sans-serif',
wordSpacing: 'normal',
}
@@ -105,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',
@@ -129,9 +112,9 @@ const imageContainer = {
margin: '20px 0',
}
const profileImage = {
// border: '1px solid #ec489a',
}
// const profileImage = {
// // border: '1px solid #ec489a',
// }
const button = {
backgroundColor: '#4887ec',
@@ -147,21 +130,4 @@ const button = {
margin: '10px 0',
}
const footer = {
margin: '20px 0',
textAlign: 'center' as const,
}
const footerText = {
fontSize: '11px',
lineHeight: '22px',
color: '#000000',
fontFamily: 'Ubuntu, Helvetica, Arial, sans-serif',
}
const footerLink = {
color: 'inherit',
textDecoration: 'none',
}
export default NewMatchEmail

View File

@@ -1,49 +1,35 @@
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 {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
email?: string
}
export const NewMessageEmail = ({
fromUser,
fromUserLover,
toUser,
channelId,
unsubscribeUrl,
}: 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}>
@@ -62,15 +48,15 @@ export const NewMessageEmail = ({
<Text style={paragraph}>{creatorName} just messaged you!</Text>
<Section style={imageContainer}>
<Link href={messagesUrl}>
<Img
src={userImgSrc}
width="375"
height="200"
alt={`${creatorName}'s profile`}
style={profileImage}
/>
</Link>
{/*<Link href={messagesUrl}>*/}
{/* <Img*/}
{/* src={userImgSrc}*/}
{/* width="375"*/}
{/* height="200"*/}
{/* alt={`${creatorName}'s profile`}*/}
{/* style={profileImage}*/}
{/* />*/}
{/*</Link>*/}
<Button href={messagesUrl} style={button}>
View message
@@ -78,15 +64,7 @@ export const NewMessageEmail = ({
</Section>
</Section>
<Section style={footer}>
<Text style={footerText}>
This e-mail has been sent to {name},{' '}
{/* <Link href={unsubscribeUrl} style={{ color: 'inherit', textDecoration: 'none' }}>
click here to unsubscribe from this type of notification
</Link>
. */}
</Text>
</Section>
<Footer unsubscribeUrl={unsubscribeUrl} email={email ?? name}/>
</Container>
</Body>
</Html>
@@ -95,75 +73,12 @@ export const NewMessageEmail = ({
NewMessageEmail.PreviewProps = {
fromUser: jamesUser,
fromUserLover: jamesLover,
fromUserProfile: jamesProfile,
toUser: sinclairUser,
channelId: 1,
email: 'someone@gmail.com',
unsubscribeUrl: 'https://compassmeet.com/unsubscribe',
} as NewMessageEmailProps
const main = {
backgroundColor: '#f4f4f4',
fontFamily: 'Arial, sans-serif',
wordSpacing: 'normal',
}
const container = {
margin: '0 auto',
maxWidth: '600px',
}
const logoContainer = {
padding: '20px 0px 5px 0px',
textAlign: 'center' as const,
backgroundColor: '#ffffff',
}
const content = {
backgroundColor: '#ffffff',
padding: '20px 25px',
}
const paragraph = {
fontSize: '18px',
lineHeight: '24px',
margin: '10px 0',
color: '#000000',
fontFamily: 'Arial, Helvetica, sans-serif',
}
const imageContainer = {
textAlign: 'center' as const,
margin: '20px 0',
}
const profileImage = {
// border: '1px solid #ec489a',
}
const button = {
backgroundColor: '#4887ec',
borderRadius: '12px',
color: '#ffffff',
fontFamily: 'Helvetica, Arial, sans-serif',
fontSize: '16px',
fontWeight: 'semibold',
textDecoration: 'none',
textAlign: 'center' as const,
display: 'inline-block',
padding: '6px 10px',
margin: '10px 0',
}
const footer = {
margin: '20px 0',
textAlign: 'center' as const,
}
const footerText = {
fontSize: '11px',
lineHeight: '22px',
color: '#000000',
fontFamily: 'Ubuntu, Helvetica, Arial, sans-serif',
}
export default NewMessageEmail

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

@@ -16,7 +16,7 @@ export const Test = (props: { name: string }) => {
}
Test.PreviewProps = {
name: 'Clarity',
name: 'Friend',
}
export default Test

View File

@@ -0,0 +1,145 @@
import {Column, Img, Link, Row, Section, Text} from "@react-email/components";
interface Props {
email?: string
unsubscribeUrl: string
}
export const Footer = ({
email,
unsubscribeUrl,
}: Props) => {
return <Section style={footer}>
<hr style={{border: 'none', borderTop: '1px solid #e0e0e0', margin: '10px 0'}}/>
<Row>
<Column align="center">
<Link href="https://github.com/CompassConnections/Compass" target="_blank">
<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>
<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
</Link>
.
</Text>
</Row>
</Section>
}
export const footer = {
margin: '20px 0',
textAlign: 'center' as const,
}
export const footerText = {
fontSize: '11px',
lineHeight: '22px',
color: '#000000',
fontFamily: 'Ubuntu, Helvetica, Arial, sans-serif',
}
export const blackLinks = {
color: 'black'
}
// const footerLink = {
// color: 'inherit',
// textDecoration: 'none',
// }
export const main = {
// backgroundColor: '#f4f4f4',
fontFamily: 'Arial, sans-serif',
wordSpacing: 'normal',
}
export const container = {
margin: '0 auto',
maxWidth: '600px',
}
export const logoContainer = {
padding: '20px 0px 5px 0px',
textAlign: 'center' as const,
backgroundColor: '#ffffff',
}
export const content = {
backgroundColor: '#ffffff',
padding: '20px 25px',
}
export const paragraph = {
// fontSize: '12px',
lineHeight: '24px',
margin: '10px 0',
color: '#000000',
// fontFamily: 'Arial, Helvetica, sans-serif',
}
export const imageContainer = {
textAlign: 'center' as const,
margin: '20px 0',
}
export const profileImage = {
// border: '1px solid #ec489a',
}
export const button = {
backgroundColor: '#4887ec',
borderRadius: '12px',
color: '#ffffff',
fontFamily: 'Helvetica, Arial, sans-serif',
fontSize: '16px',
fontWeight: 'semibold',
textDecoration: 'none',
textAlign: 'center' as const,
display: 'inline-block',
padding: '6px 10px',
margin: '10px 0',
}

View File

@@ -1,22 +1,22 @@
{
"name": "react-email-starter",
"version": "0.1.9",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "email dev",
"dev": "email dev --port 3001",
"build": "tsc -b"
},
"dependencies": {
"@react-email/components": "0.0.33",
"react": "19.0.0",
"react-dom": "19.0.0",
"react-email": "3.0.7",
"resend": "4.1.2"
"react-icons": "5.5.0",
"resend": "4.1.2",
"react": "18.2.0",
"react-dom": "18.2.0"
},
"devDependencies": {
"@types/html-to-text": "9.0.4",
"@types/prismjs": "1.26.5",
"@types/react": "19.0.10",
"@types/react-dom": "19.0.4"
"@types/react": "18.3.5",
"@types/react-dom": "18.3.0"
}
}

View File

File diff suppressed because it is too large Load Diff

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

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

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
@@ -19,4 +19,6 @@ BEGIN;
\i backend/supabase/user_events.sql
\i backend/supabase/user_notifications.sql
\i backend/supabase/functions_others.sql
\i backend/supabase/reports.sql
\i backend/supabase/bookmarked_searches.sql
COMMIT;

View File

@@ -1,23 +0,0 @@
-- This file is copied from https://github.com/manifoldmarkets/manifold/blob/main/backend/supabase/reports.sql
create table if not exists
reports (
content_id text not null,
content_owner_id text not null,
content_type text not null,
created_time timestamp with time zone default now(),
description text,
id text default uuid_generate_v4 () not null,
parent_id text,
parent_type text,
user_id text not null
);
-- Foreign Keys
alter table reports
add constraint reports_content_owner_id_fkey foreign key (content_owner_id) references users (id);
alter table reports
add constraint reports_user_id_fkey foreign key (user_id) references users (id);
-- Row Level Security
alter table reports enable row level security;

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,19 +42,18 @@ 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('mono'),
z.literal('poly'),
z.literal('open'),
z.literal('other'),
z.literal('collaboration'),
z.literal('friendship'),
z.literal('relationship'),
])
),
wants_kids_strength: z.number(),
@@ -75,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(),
@@ -101,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 || '',
@@ -46,7 +46,7 @@ export const PROD_CONFIG: EnvConfig = {
cloudRunId: 'w3txbmd3ba',
cloudRunRegion: 'uc',
supabaseInstanceId: 'ltzepxnhhnrnvovqblfr',
supabaseAnonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imx0emVweG5oaG5ybnZvdnFibGZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTU5NjczNjgsImV4cCI6MjA3MTU0MzM2OH0.pbazcrVOG7Kh_IgblRu2VAfoBe3-xheNfRzAto7xvzY',
supabaseAnonKey: process.env.NEXT_PUBLIC_SUPABASE_KEY || '',
apiEndpoint: 'api.compassmeet.com',
adminIds: [
'0vaZsIJk9zLVOWY4gb61gTrRIU73', // Martin

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 }

32
common/src/geodb.ts Normal file
View File

@@ -0,0 +1,32 @@
export const geodbHost = 'wft-geo-db.p.rapidapi.com'
export const geodbFetch = async (endpoint: string) => {
const apiKey = process.env.GEODB_API_KEY
if (!apiKey) {
return {status: 'failure', data: 'Missing GEODB API key'}
}
const baseUrl = `https://${geodbHost}/v1/geo`
const url = `${baseUrl}${endpoint}`
try {
const res = await fetch(url, {
method: 'GET',
headers: {
'X-RapidAPI-Key': apiKey,
'X-RapidAPI-Host': geodbHost,
},
})
if (!res.ok) {
throw new Error(`HTTP error! Status: ${res.status} ${await res.text()}`)
}
const data = await res.json()
console.log('geodbFetch', endpoint, data)
return {status: 'success', data}
} catch (error) {
console.log('geodbFetch', endpoint, error)
return {status: 'failure', data: error}
}
}

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,10 +1,12 @@
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,
gender: string | undefined
) => {
if (preferredGenders === undefined || gender === undefined) return true
// console.log('isPreferredGender', preferredGenders, gender)
if (preferredGenders === undefined || preferredGenders.length === 0 || gender === undefined) return true
// If simple gender preference, don't include non-binary.
if (
@@ -16,52 +18,53 @@ const isPreferredGender = (
return preferredGenders.includes(gender) || gender === 'non-binary'
}
export const areGenderCompatible = (lover1: LoverRow, lover2: LoverRow) => {
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
}

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