150 Commits
1.4.0 ... 1.5.0

Author SHA1 Message Date
MartinBraquet
17f9e72a9f Release 2025-10-25 04:19:29 +02:00
MartinBraquet
120aeed56f Add API to FAQ 2025-10-25 04:19:19 +02:00
MartinBraquet
8128c3b2d7 Update docs 2025-10-25 04:10:37 +02:00
MartinBraquet
4581a33cae Fix package import 2025-10-25 04:10:32 +02:00
MartinBraquet
d43e2af3ae Remove unused openapi 2025-10-25 04:07:46 +02:00
MartinBraquet
0283eb4d85 Massive upgrade to the API Swagger UI @ api.compassmeet.com 2025-10-25 03:42:23 +02:00
MartinBraquet
f483ae42a8 Add smoker to your filters 2025-10-25 01:31:43 +02:00
MartinBraquet
f974eba465 Move Reset filter to top 2025-10-25 01:25:24 +02:00
MartinBraquet
7d7969fe0f Reset filter 2025-10-25 01:22:01 +02:00
MartinBraquet
2a3d7e8362 Fix you filter 2025-10-25 01:21:46 +02:00
MartinBraquet
a38c03c4e0 Fix 2025-10-25 01:02:21 +02:00
MartinBraquet
342a0c612a Add filter for smoking 2025-10-25 00:58:53 +02:00
MartinBraquet
f1f9970407 Either instead of Any Kids 2025-10-24 23:03:41 +02:00
MartinBraquet
c83a3e6315 Log error 2025-10-24 23:03:17 +02:00
MartinBraquet
fbc65e7e2a Update helpers for local run 2025-10-24 23:03:04 +02:00
MartinBraquet
d9e9407cab Update doc 2025-10-24 23:02:36 +02:00
MartinBraquet
d0881b76e0 Update migrate 2025-10-24 23:02:28 +02:00
MartinBraquet
61c867b49c Add info for add new profile field 2025-10-24 23:02:17 +02:00
MartinBraquet
87de30d257 Create share notif 2025-10-24 23:02:03 +02:00
MartinBraquet
817605417c Default to DEV in local 2025-10-24 23:01:50 +02:00
MartinBraquet
65b018db2a Add contact links 2025-10-24 20:48:45 +02:00
MartinBraquet
addb52e3fa Add /donate page with OC widget 2025-10-24 19:24:58 +02:00
MartinBraquet
c3124ec7c3 Clean 2025-10-24 18:52:00 +02:00
MartinBraquet
b1caa6dfdc Remove log 2025-10-24 18:34:55 +02:00
MartinBraquet
26f28d55d9 Add filters for drinks 2025-10-24 18:31:43 +02:00
MartinBraquet
cb66688529 Make left nav bar scrollable 2025-10-24 17:20:52 +02:00
MartinBraquet
40c61f11be Speed update local run 2025-10-24 17:10:47 +02:00
MartinBraquet
9b45c75a5b Remove none 2025-10-24 16:25:16 +02:00
MartinBraquet
09425c1910 Rename college 2025-10-24 16:24:01 +02:00
MartinBraquet
591798e98c Add filter for education level 2025-10-24 16:19:37 +02:00
MartinBraquet
acdd82a680 Clean and factor out get-private-messages 2025-10-24 15:18:04 +02:00
MartinBraquet
5719ac3209 Fix age input 2025-10-24 14:20:55 +02:00
MartinBraquet
2ac687b0c2 Fix 2025-10-24 02:41:10 +02:00
MartinBraquet
a86a249f05 Add height in cm 2025-10-24 02:36:30 +02:00
MartinBraquet
e49a7b0bb4 Add backend support for setting compatibility answers 2025-10-24 02:09:46 +02:00
MartinBraquet
e904a7949c Fix RLS 2025-10-24 01:57:01 +02:00
MartinBraquet
080d8110df Delete bookmarked searches in the backend 2025-10-24 01:35:16 +02:00
MartinBraquet
d90826e851 Create bookmarked searches in the backend 2025-10-24 01:30:03 +02:00
MartinBraquet
e495da692b Improve wording in saved searches 2025-10-24 01:29:32 +02:00
MartinBraquet
52970ef93e Add fkeys to schema 2025-10-24 01:26:50 +02:00
MartinBraquet
8f641d117a Cancel deletions automated by cascade delete 2025-10-24 01:08:39 +02:00
MartinBraquet
d164ebc7da Add foreign keys 2025-10-24 01:04:18 +02:00
MartinBraquet
632cc5810d Add icons 2025-10-23 20:15:21 +02:00
MartinBraquet
e565a6c77f Set last online upon user creation 2025-10-23 18:45:37 +02:00
MartinBraquet
c1fe700d7a Add lib clean 2025-10-23 18:18:01 +02:00
MartinBraquet
06ee267804 Remove log 2025-10-23 18:00:11 +02:00
MartinBraquet
aad722c723 Add chat messages AES encryption 2025-10-23 17:57:19 +02:00
MartinBraquet
aefc58b636 Make mobile filter modal scrollable 2025-10-23 15:52:18 +02:00
MartinBraquet
fdd96507b8 Add politics filter 2025-10-23 15:41:46 +02:00
MartinBraquet
2ad87a5ec5 Update SQL schema 2025-10-23 15:25:06 +02:00
MartinBraquet
b94cdba5af Add diet 2025-10-23 14:58:57 +02:00
MartinBraquet
725261335c Reorder about 2025-10-23 14:16:50 +02:00
MartinBraquet
5fb0051fc6 Update README.md for clarity and minor corrections 2025-10-23 02:25:47 +02:00
MartinBraquet
1247847739 Update FAQ 2025-10-23 02:25:34 +02:00
MartinBraquet
18cb4e74d6 Refactor notification process 2025-10-23 02:07:45 +02:00
MartinBraquet
e07cb7fca9 Show all notifs types 2025-10-23 01:46:57 +02:00
MartinBraquet
dc54ed46f8 Add notif for New Vote Page 2025-10-23 01:38:34 +02:00
MartinBraquet
0415d86d71 Replace DROP INDEX with IF NOT EXISTS 2025-10-23 00:34:34 +02:00
MartinBraquet
b8b95be5ce Add on delete cascade 2025-10-23 00:29:14 +02:00
MartinBraquet
46820f0986 Clean 2025-10-23 00:08:54 +02:00
MartinBraquet
dcc022ac7f Add open Collective 2025-10-23 00:05:27 +02:00
MartinBraquet
9142f0d633 Autofocus 2025-10-22 23:49:28 +02:00
MartinBraquet
181c72befe Format 2025-10-22 23:49:18 +02:00
MartinBraquet
99f3459978 Remove user followers in search-users 2025-10-22 23:33:25 +02:00
MartinBraquet
75fbc9679c Fix page props 2025-10-22 23:33:12 +02:00
MartinBraquet
700b7774b1 Margin 404 2025-10-22 23:32:37 +02:00
MartinBraquet
d9f0a9b1ca Fix "younger than 99" 2025-10-22 22:59:25 +02:00
MartinBraquet
70644ff26d Prevent editor focus on mobile devices 2025-10-22 16:22:50 +02:00
MartinBraquet
bbefcc3bc8 Add indexes to push_subscriptions 2025-10-22 15:33:55 +02:00
MartinBraquet
09767dbae3 Add WPA to faq 2025-10-22 15:33:42 +02:00
MartinBraquet
57eafa95ba Fire and forget other user notif 2025-10-22 15:00:41 +02:00
MartinBraquet
f4f28a411e Typo 2025-10-22 15:00:27 +02:00
MartinBraquet
f6059ef5c7 Focus on editor upon page loading 2025-10-22 14:47:07 +02:00
MartinBraquet
e3fa4efa95 Remove expired subscriptions 2025-10-22 14:28:59 +02:00
MartinBraquet
6884a91eb8 Ignore ico 2025-10-22 14:28:23 +02:00
MartinBraquet
71ba018a42 Remove misplace favicon 2025-10-22 13:57:24 +02:00
MartinBraquet
10f5232ac3 Clean 2025-10-22 13:54:56 +02:00
MartinBraquet
78d707484d Update badge 2 2025-10-22 13:54:47 +02:00
MartinBraquet
69db66fbbb Update badge 2025-10-22 13:32:53 +02:00
MartinBraquet
99691cd7ee Factor out vapid key 2025-10-22 13:23:14 +02:00
MartinBraquet
47cef359ca Put back icons 2025-10-22 03:57:57 +02:00
MartinBraquet
046105498f Hide icons 2025-10-22 03:53:07 +02:00
MartinBraquet
4d3ef5dd2a Log payload 2025-10-22 03:43:56 +02:00
MartinBraquet
8bcd5623bf Add service worker info 2025-10-22 03:43:46 +02:00
MartinBraquet
a29b4a3a8e Remove badge 2025-10-22 03:40:15 +02:00
MartinBraquet
dee0fb396b Remove cache 2025-10-22 03:39:35 +02:00
MartinBraquet
b5c707e07f Send notif for every single private message 2025-10-22 03:25:04 +02:00
MartinBraquet
8fe35bd1d7 Add VAPID secrets 2025-10-22 03:02:34 +02:00
MartinBraquet
6c864c35cd Implement subscriptions for mobile notifications 2025-10-22 02:52:41 +02:00
MartinBraquet
f00acf6af1 Improve link accessibility 2025-10-22 01:18:45 +02:00
MartinBraquet
49e1599bc4 Fix react error 2025-10-22 00:44:22 +02:00
MartinBraquet
7311d4b724 Remove cache 2025-10-22 00:38:56 +02:00
MartinBraquet
fa44e348a2 Fix WPA theme color 2025-10-22 00:22:28 +02:00
MartinBraquet
8cba02741c Fix WPA 2025-10-22 00:01:58 +02:00
MartinBraquet
48d04d5e72 Add WPA 2025-10-21 23:37:20 +02:00
MartinBraquet
7cac25c0e2 Remove react import (2) 2025-10-21 20:50:56 +02:00
MartinBraquet
88b0fa0163 Remove react import 2025-10-21 20:32:35 +02:00
MartinBraquet
3fcef24cc9 Add SEO (including better tab titles) 2025-10-21 20:29:10 +02:00
MartinBraquet
d9fba6ce6b Fix link 2025-10-21 12:40:28 +02:00
MartinBraquet
8bc2f0c40e Fix flashing submit button 2025-10-21 12:36:54 +02:00
MartinBraquet
21254695d5 Remove unused import 2025-10-21 12:32:39 +02:00
MartinBraquet
f063f0a6f4 Add donate links 2025-10-21 12:32:00 +02:00
MartinBraquet
2d847cbcdb Add Github sponsor 2025-10-21 12:15:27 +02:00
MartinBraquet
547e99f526 Add vercel config for backup info 2025-10-20 17:36:55 +02:00
MartinBraquet
a9794cd2ee Add packages 2025-10-20 17:24:42 +02:00
MartinBraquet
c651abd8ae Add unmet deps 2025-10-20 16:45:02 +02:00
MartinBraquet
15781475b6 Rename Love to Profile 2025-10-20 16:35:59 +02:00
MartinBraquet
26a28175fd Rename LovePage 2025-10-20 16:24:22 +02:00
MartinBraquet
aa3680934b Rename lover to profile 2025-10-20 16:18:49 +02:00
MartinBraquet
0b36586ddf Rename love folder in backend 2025-10-20 16:14:23 +02:00
MartinBraquet
7b58acac0d Rename love folder 2025-10-20 16:13:43 +02:00
MartinBraquet
27bf4eadf9 Rename compatibility_answers_free 2025-10-20 16:10:41 +02:00
MartinBraquet
c8d4353888 Rename love_waitlist 2025-10-20 16:05:14 +02:00
MartinBraquet
4876ca2643 Rename love_stars 2025-10-20 16:03:28 +02:00
MartinBraquet
e06a382c94 Rename love_ships 2025-10-20 16:01:33 +02:00
MartinBraquet
d1a421ca15 Rename compatibility_prompts 2025-10-20 15:59:44 +02:00
MartinBraquet
cd3c8d89d0 Rename love_likes 2025-10-20 15:48:22 +02:00
MartinBraquet
1f943ccead Rename love_compatibility_answers 2025-10-20 15:46:16 +02:00
MartinBraquet
753776fa9a Fix 2025-10-20 15:37:33 +02:00
MartinBraquet
9787a2446e Fix 2025-10-20 15:31:39 +02:00
MartinBraquet
4cb29d274b Add floating info box 2025-10-20 15:28:59 +02:00
MartinBraquet
df55d63f99 Add security and help pages 2025-10-20 13:31:58 +02:00
MartinBraquet
236e2d48c5 Update security email 2025-10-20 13:20:31 +02:00
MartinBraquet
30d45d834f Add compat prompts number to stats 2025-10-20 13:03:26 +02:00
MartinBraquet
edf30897f2 Fix effect 2025-10-20 12:56:42 +02:00
MartinBraquet
3d31ebb576 Fix hooks 2025-10-20 12:55:16 +02:00
MartinBraquet
d3bac8bcc0 Fix vercel badge 2025-10-20 12:53:37 +02:00
MartinBraquet
a360f80cdf Add warning message about short bio 2025-10-20 12:51:28 +02:00
MartinBraquet
0cc7549546 remove log 2025-10-20 12:51:10 +02:00
MartinBraquet
283d2743e0 Fix mod 2025-10-19 22:50:09 +02:00
MartinBraquet
b431fa11fa Fix pref_gender filtering 2025-10-19 21:42:37 +02:00
MartinBraquet
648e00867f Reduce v space 2025-10-19 21:30:44 +02:00
MartinBraquet
552af7bb6b Add romantic type in filters 2025-10-19 21:25:17 +02:00
MartinBraquet
92980f7c79 Increase contrast 2025-10-19 20:19:52 +02:00
MartinBraquet
09a563bf73 Clean 2025-10-19 20:19:45 +02:00
MartinBraquet
141fa12a20 Add kids strength and other relationship filters 2025-10-19 12:36:48 +02:00
MartinBraquet
6e0035d4f3 Show profiles that don't set kids strength 2025-10-19 12:36:09 +02:00
MartinBraquet
97bac4132c Fix loc filter clearing 2025-10-19 11:11:51 +02:00
MartinBraquet
b23b0280cd Fix mobile nav contrast 2025-10-19 11:00:49 +02:00
MartinBraquet
7ac093a8d0 Fix socials contrast 2025-10-19 10:58:12 +02:00
MartinBraquet
dfc524b957 Fix 2025-10-19 00:32:48 +02:00
MartinBraquet
65ba0d348b Increase contrast for better accessibility 2025-10-19 00:11:59 +02:00
MartinBraquet
ed07031539 Fix primary key 2025-10-18 23:31:32 +02:00
MartinBraquet
93f3690344 Speed up init theme 2025-10-18 22:47:43 +02:00
MartinBraquet
1341d1356a Fix warning 2025-10-18 22:37:46 +02:00
MartinBraquet
38dcf16c03 customlink -> custom-link 2025-10-18 22:36:37 +02:00
MartinBraquet
8696a42959 SSH and view logs in one click 2025-10-18 22:36:12 +02:00
MartinBraquet
c6fc7db1e9 Move up 2025-10-18 13:17:44 +02:00
MartinBraquet
58540aca57 Add proposals and votes number to stats 2025-10-18 13:16:05 +02:00
MartinBraquet
b7b75279c2 Add warning message upon console opening 2025-10-18 12:49:36 +02:00
256 changed files with 5265 additions and 2290 deletions

4
.github/FUNDING.yml vendored
View File

@@ -1,8 +1,8 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
github: [CompassConnections] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: CompassMeet # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
open_collective: compass-connection # Replace with a single Open Collective username
ko_fi: compassconnections # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry

1
.gitignore vendored
View File

@@ -68,6 +68,7 @@ email-preview
*.jpeg
*.gif
*.svg
*.ico
*.mp4
*.mov
*.avi

View File

@@ -1,7 +1,7 @@
[![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)
![Vercel](https://deploy-badge.vercel.app/vercel/compass)
# Compass
@@ -31,8 +31,8 @@ No contribution is too small—whether its changing a color, resizing a butto
The complete, official list of tasks is available [here on ClickUp](https://sharing.clickup.com/90181043445/l/h/6-901810339879-1/bbfd32f4f4bf64b). If you are working on one task, just assign it to yourself and move its status to "in progress". If there is also a GitHub issue for that task, assign it to yourself as well.
To have edit access to the ClickUp workspace, you need an admin to manually give you permission (one time thing). To do so, just use your preferred option:
- Ask or DM an admin on Discord
To have edit access to the ClickUp workspace, you need an admin to manually give you permission (one time thing). To do so, use your preferred option:
- Ask or DM an admin on [Discord](https://discord.gg/8Vd7jzqjun)
- Email hello@compassmeet.com
- Raise an issue on GitHub
@@ -66,9 +66,9 @@ Everything is open to anyone for collaboration, but the following ones are parti
- [x] Clean up learn more page
- [x] Add dark theme
- [ ] Add profile features (intellectual interests, cause areas, personality type, conflict style, etc.)
- [ ] Add filters to search through remaining profile features (politics, religion, education level, etc.)
- [ ] Cover with tests (very important, just the test template and framework are ready)
- [ ] Add profile fields (intellectual interests, cause areas, personality type, conflict style, timezone, etc.)
- [ ] Add filters to search through remaining profile fields (politics, religion, education level, etc.)
- [ ] Cover with tests (crucial, just the test template and framework are ready)
- [ ] Make the app more user-friendly and appealing (UI/UX)
- [ ] Clean up terms and conditions (convert to Markdown)
- [ ] Clean up privacy notice (convert to Markdown)
@@ -162,7 +162,7 @@ You can also add `console.log()` statements in the code.
If you are new to Typescript or the open-source space, you could start with small changes, such as tweaking some web components or improving wording in some pages. You can find those files in `web/public/md/`.
See [development.md](docs/development.md) for additional instructions, such as adding new profile features.
See [development.md](docs/development.md) for additional instructions, such as adding new profile fields.
### Submission

View File

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

View File

@@ -161,5 +161,4 @@ docker rmi -f $(docker images -aq)
### Documentation
The API docs are available at https://api.compassmeet.com. They are defined in [openapi.json](openapi.json).
Just a few endpoints are mentioned in that JSON doc. Feel free to help by adding the remaining ones!
The API doc is available at https://api.compassmeet.com. It's dynamically prepared in [app.ts](src/app.ts).

View File

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

View File

@@ -4,16 +4,18 @@
"version": "1.0.0",
"private": true,
"scripts": {
"watch:serve": "tsx watch src/serve.ts",
"watch:compile": "npx concurrently \"tsc -b --watch --preserveWatchOutput\" \"(cd ../../common && tsc-alias --watch)\" \"(cd ../shared && tsc-alias --watch)\" \"(cd ../email && tsc-alias --watch)\" \"tsc-alias --watch\"",
"watch:serve": "nodemon -r tsconfig-paths/register --watch lib --ignore 'lib/**/*.map' src/serve.ts",
"dev": "npx concurrently -n COMPILE,SERVER -c cyan,green \"yarn watch:compile\" \"yarn watch:serve\"",
"dev": "yarn watch:serve",
"prod": "npx concurrently -n COMPILE,SERVER -c cyan,green \"yarn watch:compile\" \"yarn watch:serve\"",
"build": "yarn compile && yarn dist:clean && yarn dist:copy",
"build:fast": "yarn compile && yarn dist:copy",
"clean": "rm -rf lib && (cd ../../common && rm -rf lib) && (cd ../shared && rm -rf lib) && (cd ../email && rm -rf lib)",
"compile": "tsc -b && tsc-alias && (cd ../../common && tsc-alias) && (cd ../shared && tsc-alias) && (cd ../email && tsc-alias)",
"debug": "nodemon -r tsconfig-paths/register --watch src -e ts --watch ../../common/src --watch ../shared/src --exec \"yarn build && node --inspect-brk src/serve.ts\"",
"dist": "yarn dist:clean && yarn dist:copy",
"dist:clean": "rm -rf dist && mkdir -p dist/common/lib dist/backend/shared/lib dist/backend/api/lib dist/backend/email/lib",
"dist:copy": "rsync -a --delete ../../common/lib/ dist/common/lib && rsync -a --delete ../shared/lib/ dist/backend/shared/lib && rsync -a --delete ../email/lib/ dist/backend/email/lib && rsync -a --delete ./lib/* dist/backend/api/lib && cp ../../yarn.lock dist && cp package.json dist && cp openapi.json dist",
"dist:copy": "rsync -a --delete ../../common/lib/ dist/common/lib && rsync -a --delete ../shared/lib/ dist/backend/shared/lib && rsync -a --delete ../email/lib/ dist/backend/email/lib && rsync -a --delete ./lib/* dist/backend/api/lib && cp ../../yarn.lock dist && cp package.json dist && cp package.json dist/backend/api",
"watch": "tsc -w",
"verify": "yarn --cwd=../.. verify",
"verify:dir": "npx eslint . --max-warnings 0",
@@ -44,29 +46,32 @@
"colors": "1.4.0",
"cors": "2.8.5",
"dayjs": "1.11.4",
"express": "4.18.1",
"express": "5.0.0",
"firebase-admin": "13.5.0",
"gcp-metadata": "6.1.0",
"jsonwebtoken": "9.0.0",
"lodash": "4.17.21",
"openapi-types": "12.1.3",
"pg-promise": "11.4.1",
"posthog-node": "4.11.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"resend": "4.1.2",
"string-similarity": "4.0.4",
"swagger-jsdoc": "6.2.8",
"swagger-ui-express": "5.0.1",
"tsconfig-paths": "4.2.0",
"twitter-api-v2": "1.15.0",
"ws": "8.17.0",
"react": "18.2.0",
"react-dom": "18.2.0",
"zod": "3.21.4"
"web-push": "3.6.7",
"ws": "8.17.1",
"zod": "3.22.3"
},
"devDependencies": {
"@types/cors": "2.8.17",
"@types/react": "18.3.5",
"@types/react-dom": "18.3.0",
"@types/swagger-ui-express": "4.1.8",
"@types/web-push": "3.6.4",
"@types/ws": "8.5.10"
}
}

View File

@@ -12,6 +12,7 @@ import {blockUser, unblockUser} from './block-user'
import {getCompatibleProfilesHandler} from './compatible-profiles'
import {createComment} from './create-comment'
import {createCompatibilityQuestion} from './create-compatibility-question'
import {setCompatibilityAnswer} from './set-compatibility-answer'
import {createProfile} from './create-profile'
import {createUser} from './create-user'
import {getCompatibilityQuestions} from './get-compatibililty-questions'
@@ -19,7 +20,6 @@ import {getLikesAndShips} from './get-likes-and-ships'
import {getProfileAnswers} from './get-profile-answers'
import {getProfiles} from './get-profiles'
import {getSupabaseToken} from './get-supabase-token'
import {getDisplayUser, getUser} from './get-user'
import {getMe} from './get-me'
import {hasFreeLike} from './has-free-like'
import {health} from './health'
@@ -40,7 +40,7 @@ import {getCurrentPrivateUser} from './get-current-private-user'
import {createPrivateUserMessage} from './create-private-user-message'
import {
getChannelMemberships,
getChannelMessages,
getChannelMessagesEndpoint,
getLastSeenChannelTime,
setChannelLastSeenTime,
} from 'api/get-private-messages'
@@ -52,14 +52,26 @@ import {getNotifications} from './get-notifications'
import {updateNotifSettings} from './update-notif-setting'
import {setLastOnlineTime} from './set-last-online-time'
import swaggerUi from "swagger-ui-express"
import * as fs from "fs"
import {sendSearchNotifications} from "api/send-search-notifications";
import {sendDiscordMessage} from "common/discord/core";
import {getMessagesCount} from "api/get-messages-count";
import {createVote} from "api/create-vote";
import {vote} from "api/vote";
import {contact} from "api/contact";
import {saveSubscription} from "api/save-subscription";
import {createBookmarkedSearch} from './create-bookmarked-search'
import {deleteBookmarkedSearch} from './delete-bookmarked-search'
import {OpenAPIV3} from 'openapi-types';
import {version as pkgVersion} from './../package.json'
import {z, ZodFirstPartyTypeKind, ZodTypeAny} from "zod";
import {getUser} from "api/get-user";
// const corsOptions: CorsOptions = {
// origin: ['*'], // Only allow requests from this domain
// methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
// allowedHeaders: ['Content-Type', 'Authorization'],
// credentials: true, // if you use cookies or auth headers
// };
const allowCorsUnrestricted: RequestHandler = cors({})
function cacheController(policy?: string): RequestHandler {
@@ -107,33 +119,200 @@ const apiErrorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
export const app = express()
app.use(requestMonitoring)
const swaggerDocument = JSON.parse(fs.readFileSync("./openapi.json", "utf-8"))
swaggerDocument.info = {
...swaggerDocument.info,
description: "Compass is a free, open-source platform to help people form deep, meaningful, and lasting connections — whether platonic, romantic, or collaborative. 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: "hello@compassmeet.com",
url: "https://compassmeet.com"
const schemaCache = new WeakMap<ZodTypeAny, any>();
export function zodToOpenApiSchema(
zodObj: ZodTypeAny,
nameHint?: string
): any { // Prevent infinite recursion
if (schemaCache.has(zodObj)) {
return schemaCache.get(zodObj);
}
};
const def: any = (zodObj as any)._def;
const typeName = def.typeName as ZodFirstPartyTypeKind;
// Placeholder so recursive references can point here
const placeholder: any = {};
schemaCache.set(zodObj, placeholder);
let schema: any;
switch (typeName) {
case 'ZodString':
schema = { type: 'string' };
break;
case 'ZodNumber':
schema = { type: 'number' };
break;
case 'ZodBoolean':
schema = { type: 'boolean' };
break;
case 'ZodEnum':
schema = { type: 'string', enum: def.values };
break;
case 'ZodArray':
schema = { type: 'array', items: zodToOpenApiSchema(def.type) };
break;
case 'ZodObject': {
const shape = def.shape();
const properties: Record<string, any> = {};
const required: string[] = [];
for (const key in shape) {
const child = shape[key];
properties[key] = zodToOpenApiSchema(child, key);
if (!child.isOptional()) required.push(key);
}
schema = {
type: 'object',
properties,
...(required.length ? { required } : {}),
};
break;
}
case 'ZodRecord':
schema = {
type: 'object',
additionalProperties: zodToOpenApiSchema(def.valueType),
};
break;
case 'ZodIntersection': {
const left = zodToOpenApiSchema(def.left);
const right = zodToOpenApiSchema(def.right);
schema = { allOf: [left, right] };
break;
}
case 'ZodLazy':
// Recursive schema: use a $ref placeholder name
schema = {
$ref: `#/components/schemas/${nameHint ?? 'RecursiveType'}`,
};
break;
case 'ZodUnion':
schema = {
oneOf: def.options.map((opt: ZodTypeAny) => zodToOpenApiSchema(opt)),
};
break;
default:
schema = { type: 'string' }; // fallback for unhandled
}
Object.assign(placeholder, schema);
return schema;
}
function generateSwaggerPaths(api: typeof API) {
const paths: Record<string, any> = {};
for (const [route, config] of Object.entries(api)) {
const pathKey = '/' + route.replace(/_/g, '-'); // optional: convert underscores to dashes
const method = config.method.toLowerCase();
const summary = (config as any).summary ?? route;
// Include props in request body for POST/PUT
const operation: any = {
summary,
tags: [(config as any).tag ?? 'API'],
responses: {
200: {
description: 'OK',
content: {
'application/json': {
schema: {type: 'object'}, // could be improved by introspecting returns
},
},
},
},
};
// Include props in request body for POST/PUT
if (config.props && ['post', 'put', 'patch'].includes(method)) {
operation.requestBody = {
required: true,
content: {
'application/json': {
schema: zodToOpenApiSchema(config.props),
},
},
};
}
// Include props as query parameters for GET/DELETE
if (config.props && ['get', 'delete'].includes(method)) {
const shape = (config.props as z.ZodObject<any>)._def.shape();
operation.parameters = Object.entries(shape).map(([key, zodType]) => {
const typeMap: Record<string, string> = {
ZodString: 'string',
ZodNumber: 'number',
ZodBoolean: 'boolean',
};
const t = zodType as z.ZodTypeAny; // assert type to ZodTypeAny
return {
name: key,
in: 'query',
required: !(t.isOptional ?? false),
schema: {type: typeMap[t._def.typeName] ?? 'string'},
};
});
}
paths[pathKey] = {
[method]: operation,
}
if (config.authed) {
operation.security = [{BearerAuth: []}];
}
}
return paths;
}
const swaggerDocument: OpenAPIV3.Document = {
openapi: "3.0.0",
info: {
title: "Compass API",
description: "Compass is a free, open-source platform to help people form deep, meaningful, and lasting connections — whether platonic, romantic, or collaborative. 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: pkgVersion,
contact: {
name: "Compass",
email: "hello@compassmeet.com",
url: "https://compassmeet.com"
}
},
paths: generateSwaggerPaths(API),
components: {
securitySchemes: {
BearerAuth: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT',
},
},
}
} as OpenAPIV3.Document;
const rootPath = pathWithPrefix("/")
app.get(rootPath, swaggerUi.setup(swaggerDocument))
app.use(rootPath, swaggerUi.serve)
app.options('*', allowCorsUnrestricted)
// Triggers Missing parameter name at index 3: *; visit https://git.new/pathToRegexpError for info
// May not be necessary
// app.options('*', allowCorsUnrestricted)
const handlers: { [k in APIPath]: APIHandler<k> } = {
health: health,
'get-supabase-token': getSupabaseToken,
'get-notifications': getNotifications,
'mark-all-notifs-read': markAllNotifsRead,
'user/:username': getUser,
'user/:username/lite': getDisplayUser,
// 'user/:username': getUser,
// 'user/:username/lite': getDisplayUser,
'user/by-id/:id': getUser,
'user/by-id/:id/lite': getDisplayUser,
// 'user/by-id/:id/lite': getDisplayUser,
'user/by-id/:id/block': blockUser,
'user/by-id/:id/unblock': unblockUser,
'search-users': searchUsers,
@@ -159,6 +338,7 @@ const handlers: { [k in APIPath]: APIHandler<k> } = {
'create-comment': createComment,
'hide-comment': hideComment,
'create-compatibility-question': createCompatibilityQuestion,
'set-compatibility-answer': setCompatibilityAnswer,
'create-vote': createVote,
'vote': vote,
'contact': contact,
@@ -170,11 +350,14 @@ const handlers: { [k in APIPath]: APIHandler<k> } = {
'update-private-user-message-channel': updatePrivateUserMessageChannel,
'leave-private-user-message-channel': leavePrivateUserMessageChannel,
'get-channel-memberships': getChannelMemberships,
'get-channel-messages': getChannelMessages,
'get-channel-messages': getChannelMessagesEndpoint,
'get-channel-seen-time': getLastSeenChannelTime,
'set-channel-seen-time': setChannelLastSeenTime,
'get-messages-count': getMessagesCount,
'set-last-online-time': setLastOnlineTime,
'save-subscription': saveSubscription,
'create-bookmarked-search': createBookmarkedSearch,
'delete-bookmarked-search': deleteBookmarkedSearch,
}
Object.entries(handlers).forEach(([path, handler]) => {
@@ -202,8 +385,6 @@ Object.entries(handlers).forEach(([path, handler]) => {
}
})
// console.debug('COMPASS_API_KEY:', process.env.COMPASS_API_KEY)
// Internal Endpoints
app.post(pathWithPrefix("/internal/send-search-notifications"),
async (req, res) => {

View File

@@ -1,11 +1,11 @@
import { groupBy, sortBy } from 'lodash'
import { APIError, type APIHandler } from 'api/helpers/endpoint'
import { getCompatibilityScore } from 'common/love/compatibility-score'
import { getCompatibilityScore } from 'common/profiles/compatibility-score'
import {
getProfile,
getCompatibilityAnswers,
getGenderCompatibleProfiles,
} from 'shared/love/supabase'
} from 'shared/profiles/supabase'
import { log } from 'shared/utils'
export const getCompatibleProfilesHandler: APIHandler<

View File

@@ -0,0 +1,23 @@
import {APIHandler} from './helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
export const createBookmarkedSearch: APIHandler<'create-bookmarked-search'> = async (
props,
auth
) => {
const creator_id = auth.uid
const {search_filters, location = null, search_name = null} = props
const pg = createSupabaseDirectClient()
const inserted = await pg.one(
`
INSERT INTO bookmarked_searches (creator_id, search_filters, location, search_name)
VALUES ($1, $2, $3, $4)
RETURNING *
`,
[creator_id, search_filters, location, search_name]
)
return inserted
}

View File

@@ -13,7 +13,7 @@ export const createCompatibilityQuestion: APIHandler<
const pg = createSupabaseDirectClient()
const { data, error } = await tryCatch(
insert(pg, 'love_questions', {
insert(pg, 'compatibility_prompts', {
creator_id: creator.id,
question,
answer_type: 'compatibility_multiple_choice',

View File

@@ -0,0 +1,76 @@
import {createSupabaseDirectClient, SupabaseDirectClient} from 'shared/supabase/init'
import {Notification} from 'common/notifications'
import {insertNotificationToSupabase} from 'shared/supabase/notifications'
import {tryCatch} from "common/util/try-catch";
import {Row} from "common/supabase/utils";
export const createShareNotifications = async () => {
const createdTime = Date.now();
const id = `share-${createdTime}`
const notification: Notification = {
id,
userId: 'todo',
createdTime: createdTime,
isSeen: false,
sourceType: 'info',
sourceUpdateType: 'created',
sourceSlug: '/contact',
sourceUserAvatarUrl: 'https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/misc%2Ficon-outreach-outstrip-outreach-272151502.jpg?alt=media&token=6d6fcecb-818c-4fca-a8e0-d2d0069b9445',
title: 'Give us tips to reach more people',
sourceText: '250 members already! Tell us where and how we can best share Compass.',
}
return await createNotifications(notification)
}
export const createVoteNotifications = async () => {
const createdTime = Date.now();
const id = `vote-${createdTime}`
const notification: Notification = {
id,
userId: 'todo',
createdTime: createdTime,
isSeen: false,
sourceType: 'info',
sourceUpdateType: 'created',
sourceSlug: '/vote',
sourceUserAvatarUrl: 'https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/misc%2Fvote-icon-design-free-vector.jpg?alt=media&token=f70b6d14-0511-49b2-830d-e7cabf7bb751',
title: 'New Proposals & Votes Page',
sourceText: 'Create proposals and vote on other people\'s suggestions!',
}
return await createNotifications(notification)
}
export const createNotifications = async (notification: Notification) => {
const pg = createSupabaseDirectClient()
const {data: users, error} = await tryCatch(
pg.many<Row<'users'>>('select * from users')
)
if (error) {
console.error('Error fetching users', error)
return
}
if (!users) {
console.error('No users found')
return
}
for (const user of users) {
try {
await createNotification(user, notification, pg)
} catch (e) {
console.error('Failed to create notification', e, user)
}
}
return {
success: true,
}
}
export const createNotification = async (user: Row<'users'>, notification: Notification, pg: SupabaseDirectClient) => {
notification.userId = user.id
console.log('notification', user.username)
return await insertNotificationToSupabase(notification, pg)
}

View File

@@ -2,7 +2,7 @@ import { APIError, APIHandler } from 'api/helpers/endpoint'
import { filterDefined } from 'common/util/array'
import { uniq } from 'lodash'
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { addUsersToPrivateMessageChannel } from 'api/junk-drawer/private-messages'
import { addUsersToPrivateMessageChannel } from 'api/helpers/private-messages'
import { getPrivateUser, getUser } from 'shared/utils'
export const createPrivateUserMessageChannel: APIHandler<

View File

@@ -1,23 +1,25 @@
import { APIError, APIHandler } from 'api/helpers/endpoint'
import { getUser } from 'shared/utils'
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { MAX_COMMENT_JSON_LENGTH } from 'api/create-comment'
import { createPrivateUserMessageMain } from 'api/junk-drawer/private-messages'
import {APIError, APIHandler} from 'api/helpers/endpoint'
import {getUser} from 'shared/utils'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {MAX_COMMENT_JSON_LENGTH} from 'api/create-comment'
import {createPrivateUserMessageMain} from 'api/helpers/private-messages'
export const createPrivateUserMessage: APIHandler<
'create-private-user-message'
> = async (body, auth) => {
const { content, channelId } = body
const {content, channelId} = body
if (JSON.stringify(content).length > MAX_COMMENT_JSON_LENGTH) {
throw new APIError(
400,
`Message JSON should be less than ${MAX_COMMENT_JSON_LENGTH}`
)
}
const pg = createSupabaseDirectClient()
const creator = await getUser(auth.uid)
if (!creator) throw new APIError(401, 'Your account was not found')
if (creator.isBannedFromPosting) throw new APIError(403, 'You are banned')
const pg = createSupabaseDirectClient()
return await createPrivateUserMessageMain(
creator,
channelId,

View File

@@ -2,7 +2,7 @@ import { APIError, APIHandler } from 'api/helpers/endpoint'
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { log, getUser } from 'shared/utils'
import { HOUR_MS } from 'common/util/time'
import { removePinnedUrlFromPhotoUrls } from 'shared/love/parse-photos'
import { removePinnedUrlFromPhotoUrls } from 'shared/profiles/parse-photos'
import { track } from 'shared/analytics'
import { updateUser } from 'shared/supabase/users'
import { tryCatch } from 'common/util/try-catch'

View File

@@ -14,6 +14,7 @@ import {insert} from 'shared/supabase/utils'
import {convertPrivateUser, convertUser} from 'common/supabase/users'
import {getBucket} from "shared/firebase-utils";
import {sendWelcomeEmail} from "email/functions/helpers";
import {setLastOnlineTimeUser} from "api/set-last-online-time";
export const createUser: APIHandler<'create-user'> = async (
props,
@@ -134,6 +135,11 @@ export const createUser: APIHandler<'create-user'> = async (
} catch (e) {
console.error('Failed to sendWelcomeEmail', e)
}
try {
await setLastOnlineTimeUser(auth.uid)
} catch (e) {
console.error('Failed to set last online time', e)
}
}
return {

View File

@@ -0,0 +1,23 @@
import {APIHandler} from './helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
export const deleteBookmarkedSearch: APIHandler<'delete-bookmarked-search'> = async (
props,
auth
) => {
const creator_id = auth.uid
const {id} = props
const pg = createSupabaseDirectClient()
// Only allow deleting your own bookmarked searches
await pg.none(
`
DELETE FROM bookmarked_searches
WHERE id = $1 AND creator_id = $2
`,
[id, creator_id]
)
return {}
}

View File

@@ -24,10 +24,11 @@ export const deleteMe: APIHandler<'me/delete'> = async (body, auth) => {
// Remove user data from Supabase
const pg = createSupabaseDirectClient()
await pg.none('DELETE FROM users WHERE id = $1', [userId])
await pg.none('DELETE FROM private_users WHERE id = $1', [userId])
await pg.none('DELETE FROM profiles WHERE user_id = $1', [userId])
await pg.none('DELETE FROM bookmarked_searches WHERE creator_id = $1', [userId])
await pg.none('DELETE FROM love_compatibility_answers WHERE creator_id = $1', [userId])
// Should cascade delete in other tables
// await pg.none('DELETE FROM private_users WHERE id = $1', [userId])
// await pg.none('DELETE FROM profiles WHERE user_id = $1', [userId])
// await pg.none('DELETE FROM bookmarked_searches WHERE creator_id = $1', [userId])
// await pg.none('DELETE FROM compatibility_answers WHERE creator_id = $1', [userId])
// May need to also delete from other tables in the future (such as messages, compatibility responses, etc.)
// Delete user files from Firebase Storage

View File

@@ -17,22 +17,22 @@ export const getCompatibilityQuestions: APIHandler<
const pg = createSupabaseDirectClient()
const questions = await pg.manyOrNone<
Row<'love_questions'> & { answer_count: number; score: number }
Row<'compatibility_prompts'> & { answer_count: number; score: number }
>(
`SELECT
love_questions.*,
COUNT(love_compatibility_answers.question_id) as answer_count,
AVG(POWER(love_compatibility_answers.importance + 1 + CASE WHEN love_compatibility_answers.explanation IS NULL THEN 1 ELSE 0 END, 2)) as score
compatibility_prompts.*,
COUNT(compatibility_answers.question_id) as answer_count,
AVG(POWER(compatibility_answers.importance + 1 + CASE WHEN compatibility_answers.explanation IS NULL THEN 1 ELSE 0 END, 2)) as score
FROM
love_questions
compatibility_prompts
LEFT JOIN
love_compatibility_answers ON love_questions.id = love_compatibility_answers.question_id
compatibility_answers ON compatibility_prompts.id = compatibility_answers.question_id
WHERE
love_questions.answer_type = 'compatibility_multiple_choice'
compatibility_prompts.answer_type = 'compatibility_multiple_choice'
GROUP BY
love_questions.id
compatibility_prompts.id
ORDER BY
love_questions.importance_score
compatibility_prompts.importance_score
`,
[]
)

View File

@@ -20,10 +20,10 @@ export const getLikesAndShipsMain = async (userId: string) => {
created_time: number
}>(
`
select target_id, love_likes.created_time
from love_likes
join profiles on profiles.user_id = love_likes.target_id
join users on users.id = love_likes.target_id
select target_id, profile_likes.created_time
from profile_likes
join profiles on profiles.user_id = profile_likes.target_id
join users on users.id = profile_likes.target_id
where creator_id = $1
and looking_for_matches
and profiles.pinned_url is not null
@@ -42,10 +42,10 @@ export const getLikesAndShipsMain = async (userId: string) => {
created_time: number
}>(
`
select creator_id, love_likes.created_time
from love_likes
join profiles on profiles.user_id = love_likes.creator_id
join users on users.id = love_likes.creator_id
select creator_id, profile_likes.created_time
from profile_likes
join profiles on profiles.user_id = profile_likes.creator_id
join users on users.id = profile_likes.creator_id
where target_id = $1
and looking_for_matches
and profiles.pinned_url is not null
@@ -68,11 +68,11 @@ export const getLikesAndShipsMain = async (userId: string) => {
}>(
`
select
target1_id, target2_id, creator_id, love_ships.created_time,
target1_id, target2_id, creator_id, profile_ships.created_time,
target1_id as target_id
from love_ships
join profiles on profiles.user_id = love_ships.target1_id
join users on users.id = love_ships.target1_id
from profile_ships
join profiles on profiles.user_id = profile_ships.target1_id
join users on users.id = profile_ships.target1_id
where target2_id = $1
and profiles.looking_for_matches
and profiles.pinned_url is not null
@@ -81,11 +81,11 @@ export const getLikesAndShipsMain = async (userId: string) => {
union all
select
target1_id, target2_id, creator_id, love_ships.created_time,
target1_id, target2_id, creator_id, profile_ships.created_time,
target2_id as target_id
from love_ships
join profiles on profiles.user_id = love_ships.target2_id
join users on users.id = love_ships.target2_id
from profile_ships
join profiles on profiles.user_id = profile_ships.target2_id
join users on users.id = profile_ships.target2_id
where target1_id = $1
and profiles.looking_for_matches
and profiles.pinned_url is not null

View File

@@ -1,16 +1,15 @@
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { APIHandler } from './helpers/endpoint'
import {
convertPrivateChatMessage,
PrivateMessageChannel,
} from 'common/supabase/private-messages'
import { groupBy, mapValues } from 'lodash'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {APIError, APIHandler} from './helpers/endpoint'
import {PrivateMessageChannel,} from 'common/supabase/private-messages'
import {groupBy, mapValues} from 'lodash'
import {convertPrivateChatMessage} from "shared/supabase/messages";
import {tryCatch} from "common/util/try-catch";
export const getChannelMemberships: APIHandler<
'get-channel-memberships'
> = async (props, auth) => {
const pg = createSupabaseDirectClient()
const { channelId, lastUpdatedTime, createdTime, limit } = props
const {channelId, lastUpdatedTime, createdTime, limit} = props
let channels: PrivateMessageChannel[]
const convertRow = (r: any) => ({
@@ -24,55 +23,56 @@ export const getChannelMemberships: APIHandler<
channels = await pg.map(
`select channel_id, notify_after_time, pumcm.created_time, last_updated_time
from private_user_message_channel_members pumcm
join private_user_message_channels pumc on pumc.id= pumcm.channel_id
join private_user_message_channels pumc on pumc.id = pumcm.channel_id
where user_id = $1
and channel_id = $2
and channel_id = $2
limit $3
`,
`,
[auth.uid, channelId, limit],
convertRow
)
} else {
channels = await pg.map(
`with latest_channels as (
select distinct on (pumc.id) pumc.id as channel_id, notify_after_time, pumc.created_time,
(select created_time
from private_user_messages
where channel_id = pumc.id
and visibility != 'system_status'
and user_id != $1
order by created_time desc
limit 1) as last_updated_time, -- last_updated_time is the last possible unseen message time
pumc.last_updated_time as last_updated_channel_time -- last_updated_channel_time is the last time the channel was updated
from private_user_message_channels pumc
join private_user_message_channel_members pumcm on pumcm.channel_id = pumc.id
inner join private_user_messages pum on pumc.id = pum.channel_id
and (pum.visibility != 'introduction' or pum.user_id != $1)
where pumcm.user_id = $1
and not status = 'left'
and ($2 is null or pumcm.created_time > $2)
and ($4 is null or pumc.last_updated_time > $4)
order by pumc.id, pumc.last_updated_time desc
)
select * from latest_channels
`with latest_channels as (select distinct on (pumc.id) pumc.id as channel_id,
notify_after_time,
pumc.created_time,
(select created_time
from private_user_messages
where channel_id = pumc.id
and visibility != 'system_status'
and user_id != $1
order by created_time desc
limit 1) as last_updated_time, -- last_updated_time is the last possible unseen message time
pumc.last_updated_time as last_updated_channel_time -- last_updated_channel_time is the last time the channel was updated
from private_user_message_channels pumc
join private_user_message_channel_members pumcm on pumcm.channel_id = pumc.id
inner join private_user_messages pum on pumc.id = pum.channel_id
and (pum.visibility != 'introduction' or pum.user_id != $1)
where pumcm.user_id = $1
and not status = 'left'
and ($2 is null or pumcm.created_time > $2)
and ($4 is null or pumc.last_updated_time > $4)
order by pumc.id, pumc.last_updated_time desc)
select *
from latest_channels
order by last_updated_channel_time desc
limit $3
`,
`,
[auth.uid, createdTime ?? null, limit, lastUpdatedTime ?? null],
convertRow
)
}
if (!channels || channels.length === 0)
return { channels: [], memberIdsByChannelId: {} }
return {channels: [], memberIdsByChannelId: {}}
const channelIds = channels.map((c) => c.channel_id)
const members = await pg.map(
`select channel_id, user_id
from private_user_message_channel_members
where not user_id = $1
and channel_id in ($2:list)
and not status = 'left'
`,
and channel_id in ($2:list)
and not status = 'left'
`,
[auth.uid, channelIds],
(r) => ({
channel_id: r.channel_id as number,
@@ -91,39 +91,56 @@ export const getChannelMemberships: APIHandler<
}
}
export const getChannelMessages: APIHandler<'get-channel-messages'> = async (
export const getChannelMessagesEndpoint: APIHandler<'get-channel-messages'> = async (
props,
auth
) => {
const userId = auth.uid
return await getChannelMessages({...props, userId})
}
export async function getChannelMessages(props: {
channelId: number;
limit: number;
id?: number | undefined;
userId: string;
}) {
// console.log('initial message request', props)
const {channelId, limit, id, userId} = props
const pg = createSupabaseDirectClient()
const { channelId, limit, id } = props
return await pg.map(
const {data, error} = await tryCatch(pg.map(
`select *, created_time as created_time_ts
from private_user_messages
where channel_id = $1
and exists (select 1 from private_user_message_channel_members pumcm
where pumcm.user_id = $2
and pumcm.channel_id = $1
)
from private_user_messages
where channel_id = $1
and exists (select 1
from private_user_message_channel_members pumcm
where pumcm.user_id = $2
and pumcm.channel_id = $1)
and ($4 is null or id > $4)
and not visibility = 'system_status'
order by created_time desc
limit $3
`,
[channelId, auth.uid, limit, id],
and not visibility = 'system_status'
order by created_time desc
limit $3
`,
[channelId, userId, limit, id],
convertPrivateChatMessage
)
))
if (error) {
console.error(error)
throw new APIError(401, 'Error getting messages')
}
// console.log('final messages', data)
return data
}
export const getLastSeenChannelTime: APIHandler<
'get-channel-seen-time'
> = async (props, auth) => {
const pg = createSupabaseDirectClient()
const { channelIds } = props
const {channelIds} = props
const unseens = await pg.map(
`select distinct on (channel_id) channel_id, created_time
from private_user_seen_message_channels
where channel_id = any($1)
where channel_id = any ($1)
and user_id = $2
order by channel_id, created_time desc
`,
@@ -137,11 +154,11 @@ export const setChannelLastSeenTime: APIHandler<
'set-channel-seen-time'
> = async (props, auth) => {
const pg = createSupabaseDirectClient()
const { channelId } = props
const {channelId} = props
await pg.none(
`insert into private_user_seen_message_channels (user_id, channel_id)
values ($1, $2)
`,
`insert into private_user_seen_message_channels (user_id, channel_id)
values ($1, $2)
`,
[auth.uid, channelId]
)
}

View File

@@ -9,8 +9,8 @@ export const getProfileAnswers: APIHandler<'get-profile-answers'> = async (
const { userId } = props
const pg = createSupabaseDirectClient()
const answers = await pg.manyOrNone<Row<'love_compatibility_answers'>>(
`select * from love_compatibility_answers
const answers = await pg.manyOrNone<Row<'compatibility_answers'>>(
`select * from compatibility_answers
where
creator_id = $1
order by created_time desc

View File

@@ -1,5 +1,5 @@
import {type APIHandler} from 'api/helpers/endpoint'
import {convertRow} from 'shared/love/supabase'
import {convertRow} from 'shared/profiles/supabase'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {from, join, leftJoin, limit, orderBy, renderSql, select, where,} from 'shared/supabase/sql-builder'
import {getCompatibleProfiles} from 'api/compatible-profiles'
@@ -12,11 +12,16 @@ export type profileQueryType = {
// Search and filter parameters
name?: string | undefined,
genders?: String[] | undefined,
education_levels?: String[] | undefined,
pref_gender?: String[] | undefined,
pref_age_min?: number | undefined,
pref_age_max?: number | undefined,
drinks_min?: number | undefined,
drinks_max?: number | undefined,
pref_relation_styles?: String[] | undefined,
pref_romantic_styles?: String[] | undefined,
diet?: String[] | undefined,
political_beliefs?: String[] | undefined,
wants_kids_strength?: number | undefined,
has_kids?: number | undefined,
is_smoker?: boolean | undefined,
@@ -42,11 +47,16 @@ export const loadProfiles = async (props: profileQueryType) => {
after,
name,
genders,
education_levels,
pref_gender,
pref_age_min,
pref_age_max,
drinks_min,
drinks_max,
pref_relation_styles,
pref_romantic_styles,
diet,
political_beliefs,
wants_kids_strength,
has_kids,
is_smoker,
@@ -78,15 +88,24 @@ export const loadProfiles = async (props: profileQueryType) => {
(l) =>
(!name || l.user.name.toLowerCase().includes(name.toLowerCase())) &&
(!genders || genders.includes(l.gender)) &&
(!education_levels || education_levels.includes(l.education_level ?? '')) &&
(!pref_gender || intersection(pref_gender, l.pref_gender).length) &&
(!pref_age_min || (l.age ?? MAX_INT) >= pref_age_min) &&
(!pref_age_max || (l.age ?? MIN_INT) <= pref_age_max) &&
(!drinks_min || (l.drinks_per_month ?? MAX_INT) >= drinks_min) &&
(!drinks_max || (l.drinks_per_month ?? MIN_INT) <= drinks_max) &&
(!pref_relation_styles ||
intersection(pref_relation_styles, l.pref_relation_styles).length) &&
(!pref_romantic_styles ||
intersection(pref_romantic_styles, l.pref_romantic_styles).length) &&
(!diet ||
intersection(diet, l.diet).length) &&
(!political_beliefs ||
intersection(political_beliefs, l.political_beliefs).length) &&
(!wants_kids_strength ||
wants_kids_strength == -1 ||
!l.wants_kids_strength ||
l.wants_kids_strength == -1 ||
(wants_kids_strength >= 2
? l.wants_kids_strength >= wants_kids_strength
: l.wants_kids_strength <= wants_kids_strength)) &&
@@ -94,7 +113,7 @@ export const loadProfiles = async (props: profileQueryType) => {
has_kids == -1 ||
(has_kids == 0 && !l.has_kids) ||
(l.has_kids && l.has_kids > 0)) &&
(!is_smoker || l.is_smoker === is_smoker) &&
(is_smoker === undefined || l.is_smoker === is_smoker) &&
(l.id.toString() != skipId) &&
(!geodbCityIds ||
(l.geodb_city_id && geodbCityIds.includes(l.geodb_city_id))) &&
@@ -137,10 +156,12 @@ export const loadProfiles = async (props: profileQueryType) => {
{word}
)),
genders?.length && where(`gender = ANY($(gender))`, {gender: genders}),
genders?.length && where(`gender = ANY($(genders))`, {genders}),
education_levels?.length && where(`education_level = ANY($(education_levels))`, {education_levels}),
pref_gender?.length &&
where(`pref_gender && $(pref_gender)`, {pref_gender}),
where(`pref_gender is NULL or pref_gender = '{}' OR pref_gender && $(pref_gender)`, {pref_gender}),
pref_age_min &&
where(`age >= $(pref_age_min) or age is null`, {pref_age_min}),
@@ -148,6 +169,12 @@ export const loadProfiles = async (props: profileQueryType) => {
pref_age_max &&
where(`age <= $(pref_age_max) or age is null`, {pref_age_max}),
drinks_min &&
where(`drinks_per_month >= $(drinks_min) or drinks_per_month is null`, {drinks_min}),
drinks_max &&
where(`drinks_per_month <= $(drinks_max) or drinks_per_month is null`, {drinks_max}),
pref_relation_styles?.length &&
where(
`pref_relation_styles IS NULL OR pref_relation_styles = '{}' OR pref_relation_styles && $(pref_relation_styles)`,
@@ -160,19 +187,34 @@ export const loadProfiles = async (props: profileQueryType) => {
{pref_romantic_styles}
),
diet?.length &&
where(
`diet IS NULL OR diet = '{}' OR diet && $(diet)`,
{diet}
),
political_beliefs?.length &&
where(
`political_beliefs IS NULL OR political_beliefs = '{}' OR political_beliefs && $(political_beliefs)`,
{political_beliefs}
),
!!wants_kids_strength &&
wants_kids_strength !== -1 &&
where(
wants_kids_strength >= 2
? `wants_kids_strength >= $(wants_kids_strength)`
: `wants_kids_strength <= $(wants_kids_strength)`,
'wants_kids_strength = -1 OR wants_kids_strength IS NULL OR ' + (wants_kids_strength >= 2 ? `wants_kids_strength >= $(wants_kids_strength)` : `wants_kids_strength <= $(wants_kids_strength)`),
{wants_kids_strength}
),
has_kids === 0 && where(`has_kids IS NULL OR has_kids = 0`),
has_kids && has_kids > 0 && where(`has_kids > 0`),
is_smoker !== undefined && where(`is_smoker = $(is_smoker)`, {is_smoker}),
is_smoker !== undefined && (
where(
(is_smoker ? '' : 'is_smoker IS NULL OR ') + // smokers are rare, so we don't include the people who didn't answer if we're looking for smokers
`is_smoker = $(is_smoker)`, {is_smoker}
)
),
geodbCityIds?.length &&
where(`geodb_city_id = ANY($(geodbCityIds))`, {geodbCityIds}),

View File

@@ -17,17 +17,18 @@ export const getUser = async (props: { id: string } | { username: string }) => {
return toUserAPIResponse(user)
}
export const getDisplayUser = async (
props: { id: string } | { username: string }
) => {
const pg = createSupabaseDirectClient()
const liteUser = await pg.oneOrNone(
`select ${displayUserColumns}
from users
where ${'id' in props ? 'id' : 'username'} = $1`,
['id' in props ? props.id : props.username]
)
if (!liteUser) throw new APIError(404, 'User not found')
return removeNullOrUndefinedProps(liteUser)
}
// export const getDisplayUser = async (
// props: { id: string } | { username: string }
// ) => {
// console.log('getDisplayUser', props)
// const pg = createSupabaseDirectClient()
// const liteUser = await pg.oneOrNone(
// `select ${displayUserColumns}
// from users
// where ${'id' in props ? 'id' : 'username'} = $1`,
// ['id' in props ? props.id : props.username]
// )
// if (!liteUser) throw new APIError(404, 'User not found')
//
// return removeNullOrUndefinedProps(liteUser)
// }

View File

@@ -17,7 +17,7 @@ export const getHasFreeLike = async (userId: string) => {
const likeGivenToday = await pg.oneOrNone<object>(
`
select 1
from love_likes
from profile_likes
where creator_id = $1
and created_time at time zone 'UTC' at time zone 'America/Los_Angeles' >= (now() at time zone 'UTC' at time zone 'America/Los_Angeles')::date
and created_time at time zone 'UTC' at time zone 'America/Los_Angeles' < ((now() at time zone 'UTC' at time zone 'America/Los_Angeles')::date + interval '1 day')

View File

@@ -272,7 +272,7 @@ export const typedEndpoint = <N extends APIPath>(
if (!res.headersSent) {
// Convert bigint to number, b/c JSON doesn't support bigint.
const convertedResult = deepConvertBigIntToNumber(result)
// console.debug('API result', convertedResult)
res.status(200).json(convertedResult ?? {success: true})
}

View File

@@ -0,0 +1,260 @@
import {Json} from 'common/supabase/schema'
import {SupabaseDirectClient} from 'shared/supabase/init'
import {ChatVisibility} from 'common/chat-message'
import {User} from 'common/user'
import {first} from 'lodash'
import {log} from 'shared/monitoring/log'
import {getPrivateUser, getUser} from 'shared/utils'
import {type JSONContent} from '@tiptap/core'
import {APIError} from 'common/api/utils'
import {broadcast} from 'shared/websockets/server'
import {track} from 'shared/analytics'
import {sendNewMessageEmail} from 'email/functions/helpers'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
import webPush from 'web-push';
import {parseJsonContentToText} from "common/util/parse";
import {encryptMessage} from "shared/encryption";
dayjs.extend(utc)
dayjs.extend(timezone)
export const leaveChatContent = (userName: string) => ({
type: 'doc',
content: [
{
type: 'paragraph',
content: [{text: `${userName} left the chat`, type: 'text'}],
},
],
})
export const joinChatContent = (userName: string) => {
return {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{text: `${userName} joined the chat!`, type: 'text'}],
},
],
}
}
export const insertPrivateMessage = async (
content: Json,
channelId: number,
userId: string,
visibility: ChatVisibility,
pg: SupabaseDirectClient
) => {
const plaintext = JSON.stringify(content);
const {ciphertext, iv, tag} = encryptMessage(plaintext);
const lastMessage = await pg.one(
`insert into private_user_messages (ciphertext, iv, tag, channel_id, user_id, visibility)
values ($1, $2, $3, $4, $5, $6)
returning created_time`,
[ciphertext, iv, tag, channelId, userId, visibility]
)
await pg.none(
`update private_user_message_channels
set last_updated_time = $1
where id = $2`,
[lastMessage.created_time, channelId]
)
}
export const addUsersToPrivateMessageChannel = async (
userIds: string[],
channelId: number,
pg: SupabaseDirectClient
) => {
await Promise.all(
userIds.map((id) =>
pg.none(
`insert into private_user_message_channel_members (channel_id, user_id, role, status)
values ($1, $2, 'member', 'proposed')
on conflict do nothing
`,
[channelId, id]
)
)
)
await pg.none(
`update private_user_message_channels
set last_updated_time = now()
where id = $1`,
[channelId]
)
}
export const createPrivateUserMessageMain = async (
creator: User,
channelId: number,
content: JSONContent,
pg: SupabaseDirectClient,
visibility: ChatVisibility
) => {
log('createPrivateUserMessageMain', creator, channelId, content)
// Normally, users can only submit messages to channels that they are members of
const authorized = await pg.oneOrNone(
`select 1
from private_user_message_channel_members
where channel_id = $1
and user_id = $2`,
[channelId, creator.id]
)
if (!authorized)
throw new APIError(403, 'You are not authorized to post to this channel')
await insertPrivateMessage(content, channelId, creator.id, visibility, pg)
const privateMessage = {
content: content as Json,
channel_id: channelId,
user_id: creator.id,
}
const otherUserIds = await pg.map<string>(
`select user_id
from private_user_message_channel_members
where channel_id = $1
and user_id != $2
and status != 'left'
`,
[channelId, creator.id],
(r) => r.user_id
)
otherUserIds.concat(creator.id).forEach((otherUserId) => {
broadcast(`private-user-messages/${otherUserId}`, {})
})
// Fire and forget safely
void notifyOtherUserInChannelIfInactive(channelId, creator, content, pg)
.catch((err) => {
console.error('notifyOtherUserInChannelIfInactive failed', err)
});
track(creator.id, 'send private message', {
channelId,
otherUserIds,
})
return privateMessage
}
const notifyOtherUserInChannelIfInactive = async (
channelId: number,
creator: User,
content: JSONContent,
pg: SupabaseDirectClient
) => {
const otherUserIds = await pg.manyOrNone<{ user_id: string }>(
`select user_id
from private_user_message_channel_members
where channel_id = $1
and user_id != $2
and status != 'left'
`,
[channelId, creator.id]
)
// We're only sending notifs for 1:1 channels
if (!otherUserIds || otherUserIds.length > 1) return
const otherUserId = first(otherUserIds)
if (!otherUserId) return
// TODO: notification only for active user
const otherUser = await getUser(otherUserId.user_id)
console.debug('otherUser:', otherUser)
if (!otherUser) return
// Push notif
webPush.setVapidDetails(
'mailto:hello@compassmeet.com',
process.env.VAPID_PUBLIC_KEY!,
process.env.VAPID_PRIVATE_KEY!
);
const textContent = parseJsonContentToText(content)
// Retrieve subscription from the database
const subscriptions = await getSubscriptionsFromDB(otherUser.id, pg);
for (const subscription of subscriptions) {
try {
const payload = JSON.stringify({
title: `${creator.name}`,
body: textContent,
url: `/messages/${channelId}`,
})
console.log('Sending notification to:', subscription.endpoint, payload);
await webPush.sendNotification(subscription, payload);
} catch (err: any) {
console.log('Failed to send notification', err);
if (err.statusCode === 410 || err.statusCode === 404) {
console.warn('Removing expired subscription', subscription.endpoint);
await pg.none(
`DELETE
FROM push_subscriptions
WHERE endpoint = $1
AND user_id = $2`,
[subscription.endpoint, otherUser.id]
);
} else {
console.error('Push failed', err);
}
}
}
const startOfDay = dayjs()
.tz('America/Los_Angeles')
.startOf('day')
.toISOString()
const previousMessagesThisDayBetweenTheseUsers = await pg.one(
`select count(*)
from private_user_messages
where channel_id = $1
and user_id = $2
and created_time > $3
`,
[channelId, creator.id, startOfDay]
)
log('previous messages this day', previousMessagesThisDayBetweenTheseUsers)
if (previousMessagesThisDayBetweenTheseUsers.count > 0) return
await createNewMessageNotification(creator, otherUser, channelId)
}
const createNewMessageNotification = async (
fromUser: User,
toUser: User,
channelId: number,
) => {
const privateUser = await getPrivateUser(toUser.id)
console.debug('privateUser:', privateUser)
if (!privateUser) return
await sendNewMessageEmail(privateUser, fromUser, toUser, channelId)
}
export async function getSubscriptionsFromDB(
userId: string,
pg: SupabaseDirectClient
) {
try {
const subscriptions = await pg.manyOrNone(`
select endpoint, keys
from push_subscriptions
where user_id = $1
`, [userId]
);
return subscriptions.map(sub => ({
endpoint: sub.endpoint,
keys: sub.keys,
}));
} catch (err) {
console.error('Error fetching subscriptions', err);
return [];
}
}

View File

@@ -1,181 +0,0 @@
import { Json } from 'common/supabase/schema'
import { SupabaseDirectClient } from 'shared/supabase/init'
import { ChatVisibility } from 'common/chat-message'
import { User } from 'common/user'
import { first } from 'lodash'
import { log } from 'shared/monitoring/log'
import { getPrivateUser, getUser } from 'shared/utils'
import { type JSONContent } from '@tiptap/core'
import { APIError } from 'common/api/utils'
import { broadcast } from 'shared/websockets/server'
import { track } from 'shared/analytics'
import { sendNewMessageEmail } from 'email/functions/helpers'
import dayjs from 'dayjs'
import utc from 'dayjs/plugin/utc'
import timezone from 'dayjs/plugin/timezone'
dayjs.extend(utc)
dayjs.extend(timezone)
export const leaveChatContent = (userName: string) => ({
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ text: `${userName} left the chat`, type: 'text' }],
},
],
})
export const joinChatContent = (userName: string) => {
return {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ text: `${userName} joined the chat!`, type: 'text' }],
},
],
}
}
export const insertPrivateMessage = async (
content: Json,
channelId: number,
userId: string,
visibility: ChatVisibility,
pg: SupabaseDirectClient
) => {
const lastMessage = await pg.one(
`insert into private_user_messages (content, channel_id, user_id, visibility)
values ($1, $2, $3, $4) returning created_time`,
[content, channelId, userId, visibility]
)
await pg.none(
`update private_user_message_channels set last_updated_time = $1 where id = $2`,
[lastMessage.created_time, channelId]
)
}
export const addUsersToPrivateMessageChannel = async (
userIds: string[],
channelId: number,
pg: SupabaseDirectClient
) => {
await Promise.all(
userIds.map((id) =>
pg.none(
`insert into private_user_message_channel_members (channel_id, user_id, role, status)
values
($1, $2, 'member', 'proposed')
on conflict do nothing
`,
[channelId, id]
)
)
)
await pg.none(
`update private_user_message_channels set last_updated_time = now() where id = $1`,
[channelId]
)
}
export const createPrivateUserMessageMain = async (
creator: User,
channelId: number,
content: JSONContent,
pg: SupabaseDirectClient,
visibility: ChatVisibility
) => {
log('createPrivateUserMessageMain', creator, channelId, content)
// Normally, users can only submit messages to channels that they are members of
const authorized = await pg.oneOrNone(
`select 1
from private_user_message_channel_members
where channel_id = $1
and user_id = $2`,
[channelId, creator.id]
)
if (!authorized)
throw new APIError(403, 'You are not authorized to post to this channel')
await notifyOtherUserInChannelIfInactive(channelId, creator, pg)
await insertPrivateMessage(content, channelId, creator.id, visibility, pg)
const privateMessage = {
content: content as Json,
channel_id: channelId,
user_id: creator.id,
}
const otherUserIds = await pg.map<string>(
`select user_id from private_user_message_channel_members
where channel_id = $1 and user_id != $2
and status != 'left'
`,
[channelId, creator.id],
(r) => r.user_id
)
otherUserIds.concat(creator.id).forEach((otherUserId) => {
broadcast(`private-user-messages/${otherUserId}`, {})
})
track(creator.id, 'send private message', {
channelId,
otherUserIds,
})
return privateMessage
}
const notifyOtherUserInChannelIfInactive = async (
channelId: number,
creator: User,
pg: SupabaseDirectClient
) => {
const otherUserIds = await pg.manyOrNone<{ user_id: string }>(
`select user_id from private_user_message_channel_members
where channel_id = $1 and user_id != $2
and status != 'left'
`,
[channelId, creator.id]
)
// We're only sending notifs for 1:1 channels
if (!otherUserIds || otherUserIds.length > 1) return
const otherUserId = first(otherUserIds)
if (!otherUserId) return
const startOfDay = dayjs()
.tz('America/Los_Angeles')
.startOf('day')
.toISOString()
const previousMessagesThisDayBetweenTheseUsers = await pg.one(
`select count(*) from private_user_messages
where channel_id = $1
and user_id = $2
and created_time > $3
`,
[channelId, creator.id, startOfDay]
)
log('previous messages this day', previousMessagesThisDayBetweenTheseUsers)
if (previousMessagesThisDayBetweenTheseUsers.count > 0) return
// TODO: notification only for active user
const otherUser = await getUser(otherUserId.user_id)
console.debug('otherUser:', otherUser)
if (!otherUser) return
await createNewMessageNotification(creator, otherUser, channelId)
}
const createNewMessageNotification = async (
fromUser: User,
toUser: User,
channelId: number
) => {
const privateUser = await getPrivateUser(toUser.id)
console.debug('privateUser:', privateUser)
if (!privateUser) return
await sendNewMessageEmail(privateUser, fromUser, toUser, channelId)
}

View File

@@ -4,7 +4,7 @@ import { createSupabaseDirectClient } from 'shared/supabase/init'
import {
insertPrivateMessage,
leaveChatContent,
} from 'api/junk-drawer/private-messages'
} from 'api/helpers/private-messages'
export const leavePrivateUserMessageChannel: APIHandler<
'leave-private-user-message-channel'

View File

@@ -1,6 +1,6 @@
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { APIError, APIHandler } from './helpers/endpoint'
import { createLoveLikeNotification } from 'shared/create-love-notification'
import { createProfileLikeNotification } from 'shared/create-profile-notification'
import { getHasFreeLike } from './has-free-like'
import { log } from 'shared/utils'
import { tryCatch } from 'common/util/try-catch'
@@ -15,7 +15,7 @@ export const likeProfile: APIHandler<'like-profile'> = async (props, auth) => {
if (remove) {
const { error } = await tryCatch(
pg.none(
'delete from love_likes where creator_id = $1 and target_id = $2',
'delete from profile_likes where creator_id = $1 and target_id = $2',
[creatorId, targetUserId]
)
)
@@ -28,8 +28,8 @@ export const likeProfile: APIHandler<'like-profile'> = async (props, auth) => {
// Check if like already exists
const { data: existing } = await tryCatch(
pg.oneOrNone<Row<'love_likes'>>(
'select * from love_likes where creator_id = $1 and target_id = $2',
pg.oneOrNone<Row<'profile_likes'>>(
'select * from profile_likes where creator_id = $1 and target_id = $2',
[creatorId, targetUserId]
)
)
@@ -48,8 +48,8 @@ export const likeProfile: APIHandler<'like-profile'> = async (props, auth) => {
// Insert the new like
const { data, error } = await tryCatch(
pg.one<Row<'love_likes'>>(
'insert into love_likes (creator_id, target_id) values ($1, $2) returning *',
pg.one<Row<'profile_likes'>>(
'insert into profile_likes (creator_id, target_id) values ($1, $2) returning *',
[creatorId, targetUserId]
)
)
@@ -59,7 +59,7 @@ export const likeProfile: APIHandler<'like-profile'> = async (props, auth) => {
}
const continuation = async () => {
await createLoveLikeNotification(data)
await createProfileLikeNotification(data)
}
return {

View File

@@ -0,0 +1,41 @@
import {APIError, APIHandler} from './helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
export const saveSubscription: APIHandler<'save-subscription'> = async (body, auth) => {
const {subscription} = body
if (!subscription?.endpoint || !subscription?.keys) {
throw new APIError(400, `Invalid subscription object`)
}
const userId = auth?.uid
try {
const pg = createSupabaseDirectClient()
// Check if a subscription already exists
const exists = await pg.oneOrNone(
'select id from push_subscriptions where endpoint = $1',
[subscription.endpoint]
);
if (exists) {
// Already exists, optionally update keys and userId
await pg.none(
'update push_subscriptions set keys = $1, user_id = $2 where id = $3',
[subscription.keys, userId, exists.id]
);
} else {
await pg.none(
`insert into push_subscriptions(endpoint, keys, user_id) values($1, $2, $3)
on conflict(endpoint) do update set keys = excluded.keys
`,
[subscription.endpoint, subscription.keys, userId]
);
}
return {success: true};
} catch (err) {
console.error('Error saving subscription', err);
throw new APIError(500, `Failed to save subscription`)
}
}

View File

@@ -20,15 +20,15 @@ export const searchUsers: APIHandler<'search-users'> = async (props, auth) => {
const pg = createSupabaseDirectClient()
const offset = page * limit
const userId = auth?.uid
const searchFollowersSQL = getSearchUserSQL({ term, offset, limit, userId })
// const userId = auth?.uid
// const searchFollowersSQL = getSearchUserSQL({ term, offset, limit, userId })
const searchAllSQL = getSearchUserSQL({ term, offset, limit })
const [followers, all] = await Promise.all([
pg.map(searchFollowersSQL, null, convertUser),
const [all] = await Promise.all([
// pg.map(searchFollowersSQL, null, convertUser),
pg.map(searchAllSQL, null, convertUser),
])
return uniqBy([...followers, ...all], 'id')
return uniqBy([...all], 'id')
.map(toUserAPIResponse)
.slice(0, limit)
}
@@ -39,17 +39,18 @@ function getSearchUserSQL(props: {
limit: number
userId?: string // search only this user's followers
}) {
const { term, userId } = props
const { term } = props
return renderSql(
userId
? [
select('users.*'),
from('users'),
join('user_follows on user_follows.follow_id = users.id'),
where('user_follows.user_id = $1', [userId]),
]
: [select('*'), from('users')],
// userId
// ? [
// select('users.*'),
// from('users'),
// join('user_follows on user_follows.follow_id = users.id'),
// where('user_follows.user_id = $1', [userId]),
// ]
// :
[select('*'), from('users')],
term
? [
where(

View File

@@ -3,7 +3,7 @@ import {from, renderSql, select} from "shared/supabase/sql-builder";
import {loadProfiles, profileQueryType} from "api/get-profiles";
import {Row} from "common/supabase/utils";
import {sendSearchAlertsEmail} from "email/functions/helpers";
import {MatchesByUserType} from "common/love/bookmarked_searches";
import {MatchesByUserType} from "common/profiles/bookmarked_searches";
import {keyBy} from "lodash";
export function convertSearchRow(row: any): any {

View File

@@ -1,3 +1,4 @@
import "tsconfig-paths/register";
import * as admin from 'firebase-admin'
import {initAdmin} from 'shared/init-admin'
import {loadSecretsToEnv} from 'common/secrets'

View File

@@ -0,0 +1,34 @@
import {APIHandler} from './helpers/endpoint'
import {createSupabaseDirectClient} from 'shared/supabase/init'
import {Row} from 'common/supabase/utils'
export const setCompatibilityAnswer: APIHandler<'set-compatibility-answer'> = async (
{questionId, multipleChoice, prefChoices, importance, explanation},
auth
) => {
const pg = createSupabaseDirectClient()
const result = await pg.one<Row<'compatibility_answers'>>({
text: `
INSERT INTO compatibility_answers
(creator_id, question_id, multiple_choice, pref_choices, importance, explanation)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (question_id, creator_id)
DO UPDATE SET multiple_choice = EXCLUDED.multiple_choice,
pref_choices = EXCLUDED.pref_choices,
importance = EXCLUDED.importance,
explanation = EXCLUDED.explanation
RETURNING *
`,
values: [
auth.uid,
questionId,
multipleChoice,
prefChoices,
importance,
explanation ?? null,
],
})
return result
}

View File

@@ -6,7 +6,12 @@ export const setLastOnlineTime: APIHandler<'set-last-online-time'> = async (
auth
) => {
if (!auth || !auth.uid) return
await setLastOnlineTimeUser(auth.uid)
// console.log('setLastOnline')
}
export const setLastOnlineTimeUser = async (userId: string) => {
const pg = createSupabaseDirectClient()
await pg.none(`
INSERT INTO user_activity (user_id, last_online_time)
@@ -16,7 +21,6 @@ export const setLastOnlineTime: APIHandler<'set-last-online-time'> = async (
SET last_online_time = EXCLUDED.last_online_time
WHERE user_activity.last_online_time < now() - interval '1 minute';
`,
[auth.uid]
[userId]
)
// console.log('setLastOnline')
}

View File

@@ -1,6 +1,6 @@
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { APIError, APIHandler } from './helpers/endpoint'
import { createLoveShipNotification } from 'shared/create-love-notification'
import { createProfileShipNotification } from 'shared/create-profile-notification'
import { log } from 'shared/utils'
import { tryCatch } from 'common/util/try-catch'
import { insert } from 'shared/supabase/utils'
@@ -14,7 +14,7 @@ export const shipProfiles: APIHandler<'ship-profiles'> = async (props, auth) =>
// Check if ship already exists or with swapped target IDs
const existing = await tryCatch(
pg.oneOrNone<{ ship_id: string }>(
`select ship_id from love_ships
`select ship_id from profile_ships
where creator_id = $1
and (
target1_id = $2 and target2_id = $3
@@ -33,7 +33,7 @@ export const shipProfiles: APIHandler<'ship-profiles'> = async (props, auth) =>
if (existing.data) {
if (remove) {
const { error } = await tryCatch(
pg.none('delete from love_ships where ship_id = $1', [
pg.none('delete from profile_ships where ship_id = $1', [
existing.data.ship_id,
])
)
@@ -48,7 +48,7 @@ export const shipProfiles: APIHandler<'ship-profiles'> = async (props, auth) =>
// Insert the new ship
const { data, error } = await tryCatch(
insert(pg, 'love_ships', {
insert(pg, 'profile_ships', {
creator_id: creatorId,
target1_id: targetUserId1,
target2_id: targetUserId2,
@@ -61,8 +61,8 @@ export const shipProfiles: APIHandler<'ship-profiles'> = async (props, auth) =>
const continuation = async () => {
await Promise.all([
createLoveShipNotification(data, data.target1_id),
createLoveShipNotification(data, data.target2_id),
createProfileShipNotification(data, data.target1_id),
createProfileShipNotification(data, data.target2_id),
])
}

View File

@@ -14,7 +14,7 @@ export const starProfile: APIHandler<'star-profile'> = async (props, auth) => {
if (remove) {
const { error } = await tryCatch(
pg.none(
'delete from love_stars where creator_id = $1 and target_id = $2',
'delete from profile_stars where creator_id = $1 and target_id = $2',
[creatorId, targetUserId]
)
)
@@ -27,8 +27,8 @@ export const starProfile: APIHandler<'star-profile'> = async (props, auth) => {
// Check if star already exists
const { data: existing } = await tryCatch(
pg.oneOrNone<Row<'love_stars'>>(
'select * from love_stars where creator_id = $1 and target_id = $2',
pg.oneOrNone<Row<'profile_stars'>>(
'select * from profile_stars where creator_id = $1 and target_id = $2',
[creatorId, targetUserId]
)
)
@@ -40,7 +40,7 @@ export const starProfile: APIHandler<'star-profile'> = async (props, auth) => {
// Insert the new star
const { error } = await tryCatch(
insert(pg, 'love_stars', { creator_id: creatorId, target_id: targetUserId })
insert(pg, 'profile_stars', { creator_id: creatorId, target_id: targetUserId })
)
if (error) {

View File

@@ -1,5 +1,5 @@
import { APIError, APIHandler } from 'api/helpers/endpoint'
import { removePinnedUrlFromPhotoUrls } from 'shared/love/parse-photos'
import { removePinnedUrlFromPhotoUrls } from 'shared/profiles/parse-photos'
import { createSupabaseDirectClient } from 'shared/supabase/init'
import { updateUser } from 'shared/supabase/users'
import { log } from 'shared/utils'

View File

@@ -9,7 +9,8 @@ set -e
SERVICE_NAME="api"
SERVICE_GROUP="${SERVICE_NAME}-group"
ZONE="us-west1-c"
ENV=${1:-dev}
#ENV=${1:-dev}
ENV=prod
case $ENV in
dev)
@@ -28,10 +29,19 @@ INSTANCE_ID=$(gcloud compute instances list \
--format="value(name)" \
--limit=1)
echo "Forwarding debugging port 9229 to ${INSTANCE_ID}. Open chrome://inspect in Chrome to connect."
echo gcloud compute ssh ${INSTANCE_ID} --project=${GCLOUD_PROJECT} --zone=${ZONE}
gcloud compute ssh ${INSTANCE_ID} \
--project=${GCLOUD_PROJECT} \
--zone=${ZONE} \
#echo "Forwarding debugging port 9229 to ${INSTANCE_ID}. Open chrome://inspect in Chrome to connect."
if [ "$1" = "logs" ]; then
CMD=(--command="sudo docker logs -f \$(sudo docker ps -alq)")
else
CMD=()
fi
gcloud compute ssh "${INSTANCE_ID}" \
--project="${GCLOUD_PROJECT}" \
--zone="${ZONE}" \
"${CMD[@]}"
# -- \
# -NL 9229:localhost:9229

View File

@@ -8,6 +8,7 @@
"tsBuildInfoFile": "lib/tsconfig.tsbuildinfo",
"sourceMap": true,
"strict": true,
"resolveJsonModule": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"target": "esnext",
@@ -50,6 +51,7 @@
"compileOnSave": true,
"include": [
"src/**/*.ts",
"openapi.json"
"package.json",
"backend/api/package.json"
]
}

View File

@@ -4,9 +4,9 @@ import {sendEmail} from './send-email'
import {NewMessageEmail} from '../new-message'
import {NewEndorsementEmail} from '../new-endorsement'
import {Test} from '../test'
import {getProfile} from 'shared/love/supabase'
import {getProfile} from 'shared/profiles/supabase'
import { render } from "@react-email/render"
import {MatchesType} from "common/love/bookmarked_searches";
import {MatchesType} from "common/profiles/bookmarked_searches";
import NewSearchAlertsEmail from "email/new-search_alerts";
import WelcomeEmail from "email/welcome";

View File

@@ -1,4 +1,4 @@
import {ProfileRow} from 'common/love/profile'
import {ProfileRow} from 'common/profiles/profile'
import type {User} from 'common/user'
// for email template testing
@@ -17,7 +17,6 @@ export const mockUser: User = {
id: '0k1suGSJKVUnHbCPEhHNpgZPkUP2',
username: 'Sinclair',
name: 'Sinclair',
// url: 'https://manifold.love/Sinclair',
// isAdmin: true,
// isTrustworthy: false,
link: {
@@ -47,7 +46,7 @@ export const sinclairProfile: ProfileRow = {
has_kids: 0,
is_smoker: false,
drinks_per_month: 0,
is_vegetarian_or_vegan: null,
diet: null,
political_beliefs: ['e/acc', 'libertarian'],
religious_belief_strength: null,
religious_beliefs: null,
@@ -148,7 +147,7 @@ export const jamesProfile: ProfileRow = {
has_kids: 0,
is_smoker: false,
drinks_per_month: 5,
is_vegetarian_or_vegan: null,
diet: null,
political_beliefs: ['libertarian'],
religious_belief_strength: null,
religious_beliefs: '',

View File

@@ -1,6 +1,6 @@
import {Body, Button, Container, Head, Html, Preview, Section, Text,} from '@react-email/components'
import {DOMAIN} from 'common/envs/constants'
import {type ProfileRow} from 'common/love/profile'
import {type ProfileRow} from 'common/profiles/profile'
import {type User} from 'common/user'
import {jamesProfile, jamesUser, mockUser} from './functions/mock'
import {Footer} from "email/utils";
@@ -21,7 +21,7 @@ export const NewMatchEmail = ({
email
}: NewMatchEmailProps) => {
const name = onUser.name.split(' ')[0]
// const userImgSrc = getLoveOgImageUrl(matchedWithUser, matchedProfile)
// const userImgSrc = getOgImageUrl(matchedWithUser, matchedProfile)
const userUrl = `https://${DOMAIN}/${matchedWithUser.username}`
return (

View File

@@ -1,6 +1,6 @@
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 {type ProfileRow} from 'common/profiles/profile'
import {jamesProfile, jamesUser, mockUser,} from './functions/mock'
import {DOMAIN} from 'common/envs/constants'
import {button, container, content, Footer, imageContainer, main, paragraph} from "email/utils";
@@ -25,7 +25,7 @@ export const NewMessageEmail = ({
const name = toUser.name.split(' ')[0]
const creatorName = fromUser.name
const messagesUrl = `https://${DOMAIN}/messages/${channelId}`
// const userImgSrc = getLoveOgImageUrl(fromUser, fromUserProfile)
// const userImgSrc = getOgImageUrl(fromUser, fromUserProfile)
return (
<Html>

View File

@@ -3,7 +3,7 @@ import {type User} from 'common/user'
import {mockUser,} from './functions/mock'
import {DOMAIN} from 'common/envs/constants'
import {container, content, Footer, main, paragraph} from "email/utils";
import {MatchesType} from "common/love/bookmarked_searches";
import {MatchesType} from "common/profiles/bookmarked_searches";
import {formatFilters, locationType} from "common/searches"
import {FilterFields} from "common/filters";

View File

@@ -14,10 +14,6 @@ select
from
temp_users;
-- Rename temp_love_messages
-- alter table temp_love_messages
-- rename to private_user_messages;
-- alter table private_user_messages
-- alter column channel_id set not null,
-- alter column content set not null,

View File

@@ -12,7 +12,7 @@ DB_USER="postgres"
PORT="5432"
psql -U $DB_USER -d postgres -h $DB_NAME -p $PORT -w \
-f ./love-stars-dump.sql \
-f ./....sql \
# psql -U $DB_USER -d postgres -h $DB_NAME -p $PORT -w \
@@ -25,8 +25,5 @@ psql -U $DB_USER -d postgres -h $DB_NAME -p $PORT -w \
# -f ../supabase/private_users.sql \
# -f ../supabase/users.sql
# psql -U $DB_USER -d postgres -h $DB_NAME -p $PORT -w \
# -f './import-love-finalize.sql'
echo "Done"
)

View File

@@ -1,19 +1,24 @@
import {initAdmin} from 'shared/init-admin'
import {loadSecretsToEnv} from 'common/secrets'
import {createSupabaseDirectClient, type SupabaseDirectClient,} from 'shared/supabase/init'
import {getServiceAccountCredentials} from "shared/firebase-utils";
initAdmin()
import {refreshConfig} from "common/envs/prod";
export const runScript = async (
main: (services: { pg: SupabaseDirectClient }) => Promise<any> | any
) => {
const credentials = getServiceAccountCredentials()
await loadSecretsToEnv(credentials)
initAdmin()
await initEnvVariables()
console.debug('Environment variables in runScript:')
for (const k of Object.keys(process.env)) console.debug(`${k}=${process.env[k]}`)
console.debug('runScript: creating pg client...')
const pg = createSupabaseDirectClient()
console.debug('runScript: running main...')
await main({pg})
process.exit()
}
export async function initEnvVariables() {
const {config} = await import('dotenv')
config({ path: __dirname + '/../../.env' })
refreshConfig()
}

View File

@@ -4,9 +4,9 @@ import { createSupabaseDirectClient } from './supabase/init'
import { getNotificationDestinationsForUser } from 'common/user-notification-preferences'
import { Notification } from 'common/notifications'
import { insertNotificationToSupabase } from './supabase/notifications'
import { getProfile } from './love/supabase'
import { getProfile } from 'shared/profiles/supabase'
export const createLoveLikeNotification = async (like: Row<'love_likes'>) => {
export const createProfileLikeNotification = async (like: Row<'profile_likes'>) => {
const { creator_id, target_id, like_id } = like
const targetPrivateUser = await getPrivateUser(target_id)
@@ -16,7 +16,7 @@ export const createLoveLikeNotification = async (like: Row<'love_likes'>) => {
const { sendToBrowser } = getNotificationDestinationsForUser(
targetPrivateUser,
'new_love_like'
'new_profile_like'
)
if (!sendToBrowser) return
@@ -24,11 +24,11 @@ export const createLoveLikeNotification = async (like: Row<'love_likes'>) => {
const notification: Notification = {
id,
userId: target_id,
reason: 'new_love_like',
reason: 'new_profile_like',
createdTime: Date.now(),
isSeen: false,
sourceId: like_id,
sourceType: 'love_like',
sourceType: 'profile_like',
sourceUpdateType: 'created',
sourceUserName: profile.user.name,
sourceUserUsername: profile.user.username,
@@ -39,8 +39,8 @@ export const createLoveLikeNotification = async (like: Row<'love_likes'>) => {
return await insertNotificationToSupabase(notification, pg)
}
export const createLoveShipNotification = async (
ship: Row<'love_ships'>,
export const createProfileShipNotification = async (
ship: Row<'profile_ships'>,
recipientId: string
) => {
const { creator_id, target1_id, target2_id, ship_id } = ship
@@ -61,7 +61,7 @@ export const createLoveShipNotification = async (
const { sendToBrowser } = getNotificationDestinationsForUser(
targetPrivateUser,
'new_love_ship'
'new_profile_ship'
)
if (!sendToBrowser) return
@@ -69,11 +69,11 @@ export const createLoveShipNotification = async (
const notification: Notification = {
id,
userId: recipientId,
reason: 'new_love_ship',
reason: 'new_profile_ship',
createdTime: Date.now(),
isSeen: false,
sourceId: ship_id,
sourceType: 'love_ship',
sourceType: 'profile_ship',
sourceUpdateType: 'created',
sourceUserName: profile.user.name,
sourceUserUsername: profile.user.username,

View File

@@ -0,0 +1,58 @@
import crypto from "crypto";
import {ENV_CONFIG} from "common/envs/constants";
/**
* MASTER_KEY must be a 32-byte Buffer (AES-256).
* Load it from a secrets manager at runtime; do NOT hardcode.
*/
let _MASTER_KEY: Buffer | null = null
const getMasterKey = () => {
if (_MASTER_KEY) return _MASTER_KEY
if (ENV_CONFIG.dbEncryptionKey) {
const MASTER_KEY_BASE64 = ENV_CONFIG.dbEncryptionKey
_MASTER_KEY = Buffer.from(MASTER_KEY_BASE64, "base64")
if (_MASTER_KEY.length !== 32) throw new Error("MASTER_KEY must be 32 bytes")
}
if (!_MASTER_KEY) throw new Error("MASTER_KEY not set")
return _MASTER_KEY
}
/**
* Encrypt a UTF-8 message string into base64 ciphertext + iv + tag.
* The IV makes the encryption probabilistic to ensure uniqueness in ciphertexts even when encrypting the same plaintext
* multiple times and has therefore no intent of being secret. The authentication tag works similar to a MAC.
* It's used to prove the authenticity and integrity of a message
*/
export function encryptMessage(plaintext: string) {
const iv = crypto.randomBytes(12); // 96-bit IV, recommended for AES-GCM
const cipher = crypto.createCipheriv("aes-256-gcm", getMasterKey(), iv);
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const tag = cipher.getAuthTag();
// console.debug(plaintext, iv, ciphertext, tag)
return {
ciphertext: ciphertext.toString("base64"),
iv: iv.toString("base64"),
tag: tag.toString("base64"),
};
}
/**
* Decrypt base64 ciphertext + iv + tag -> plaintext string.
* Throws on auth failure.
*/
export function decryptMessage({ciphertext, iv, tag}: { ciphertext: string; iv: string; tag: string; }) {
const ivBuf = Buffer.from(iv, "base64");
const ctBuf = Buffer.from(ciphertext, "base64");
const tagBuf = Buffer.from(tag, "base64");
const decipher = crypto.createDecipheriv("aes-256-gcm", getMasterKey(), ivBuf);
decipher.setAuthTag(tagBuf);
const plaintext = Buffer.concat([decipher.update(ctBuf), decipher.final()]).toString("utf8");
// console.debug("Decrypted message:", plaintext);
return plaintext;
}

View File

@@ -1,5 +1,5 @@
import { areGenderCompatible } from 'common/love/compatibility-util'
import { type Profile, type ProfileRow } from 'common/love/profile'
import { areGenderCompatible } from 'common/profiles/compatibility-util'
import { type Profile, type ProfileRow } from 'common/profiles/profile'
import { type User } from 'common/user'
import { Row } from 'common/supabase/utils'
import { createSupabaseDirectClient } from 'shared/supabase/init'
@@ -24,14 +24,14 @@ export function convertRow(row: ProfileAndUserRow | undefined): Profile | null {
return profile as Profile
}
const LOVER_COLS = 'profiles.*, name, username, users.data as user'
const PROFILE_COLS = 'profiles.*, name, username, users.data as user'
export const getProfile = async (userId: string) => {
const pg = createSupabaseDirectClient()
return await pg.oneOrNone(
`
select
${LOVER_COLS}
${PROFILE_COLS}
from
profiles
join
@@ -49,7 +49,7 @@ export const getProfiles = async (userIds: string[]) => {
return await pg.map(
`
select
${LOVER_COLS}
${PROFILE_COLS}
from
profiles
join
@@ -67,7 +67,7 @@ export const getGenderCompatibleProfiles = async (profile: ProfileRow) => {
const profiles = await pg.map(
`
select
${LOVER_COLS}
${PROFILE_COLS}
from profiles
join
users on users.id = profiles.user_id
@@ -92,7 +92,7 @@ export const getCompatibleProfiles = async (
return await pg.map(
`
select
${LOVER_COLS}
${PROFILE_COLS}
from profiles
join
users on users.id = profiles.user_id
@@ -122,9 +122,9 @@ export const getCompatibleProfiles = async (
export const getCompatibilityAnswers = async (userIds: string[]) => {
const pg = createSupabaseDirectClient()
return await pg.manyOrNone<Row<'love_compatibility_answers'>>(
return await pg.manyOrNone<Row<'compatibility_answers'>>(
`
select * from love_compatibility_answers
select * from compatibility_answers
where creator_id = any($1)
`,
[userIds]

View File

@@ -11,7 +11,7 @@ export {SupabaseClient} from 'common/supabase/utils'
export const pgp = pgPromise({
error(err: any, e: pgPromise.IEventContext) {
// Read more: https://node-postgres.com/apis/pool#error
log.error('pgPromise background error', {
log.error(`pgPromise background error: ${err?.detail}`, {
error: err,
event: e,
})

View File

@@ -0,0 +1,48 @@
import {convertSQLtoTS, Row, tsToMillis} from "common/supabase/utils";
import {ChatMessage, PrivateChatMessage} from "common/chat-message";
import {decryptMessage} from "shared/encryption";
export type DbPrivateChatMessage = PrivateChatMessage & {
ciphertext: string
iv: string
tag: string
}
export const convertChatMessage = (row: Row<'private_user_messages'>) =>
convertSQLtoTS<'private_user_messages', ChatMessage>(row, {
created_time: tsToMillis as any,
})
export const convertPrivateChatMessage = (row: Row<'private_user_messages'>) => {
const message = convertSQLtoTS<'private_user_messages', DbPrivateChatMessage>(
row,
{created_time: tsToMillis as any,}
);
parseMessageObject(message);
return message
}
type MessageObject = Omit<ChatMessage, "id"> & { id: number; createdTimeTs: string } & {
ciphertext: string;
iv: string;
tag: string
}
export function parseMessageObject(message: MessageObject) {
if (message.ciphertext && message.iv && message.tag) {
const plaintText = decryptMessage({
ciphertext: message.ciphertext,
iv: message.iv,
tag: message.tag,
});
message.content = JSON.parse(plaintText)
delete (message as any).ciphertext
delete (message as any).iv
delete (message as any).tag
}
}
export function getDecryptedMessage(message: MessageObject) {
parseMessageObject(message)
return message.content
}

View File

@@ -8,6 +8,13 @@ CREATE TABLE IF NOT EXISTS bookmarked_searches (
search_name TEXT DEFAULT NULL
);
ALTER TABLE bookmarked_searches
ADD CONSTRAINT bookmarked_searches_creator_id_fkey
FOREIGN KEY (creator_id)
REFERENCES users(id)
ON DELETE CASCADE;
-- Row Level Security
ALTER TABLE bookmarked_searches ENABLE ROW LEVEL SECURITY;
@@ -17,17 +24,17 @@ DROP POLICY IF EXISTS "public read" ON bookmarked_searches;
CREATE POLICY "public read" ON bookmarked_searches
FOR SELECT USING (true);
DROP POLICY IF EXISTS "self delete" ON bookmarked_searches;
CREATE POLICY "self delete" ON bookmarked_searches
FOR DELETE USING (creator_id = firebase_uid());
DROP POLICY IF EXISTS "self insert" ON bookmarked_searches;
CREATE POLICY "self insert" ON bookmarked_searches
FOR INSERT WITH CHECK (creator_id = firebase_uid());
DROP POLICY IF EXISTS "self update" ON bookmarked_searches;
CREATE POLICY "self update" ON bookmarked_searches
FOR UPDATE USING (creator_id = firebase_uid());
-- DROP POLICY IF EXISTS "self delete" ON bookmarked_searches;
-- CREATE POLICY "self delete" ON bookmarked_searches
-- FOR DELETE USING (creator_id = firebase_uid());
--
-- DROP POLICY IF EXISTS "self insert" ON bookmarked_searches;
-- CREATE POLICY "self insert" ON bookmarked_searches
-- FOR INSERT WITH CHECK (creator_id = firebase_uid());
--
-- DROP POLICY IF EXISTS "self update" ON bookmarked_searches;
-- CREATE POLICY "self update" ON bookmarked_searches
-- FOR UPDATE USING (creator_id = firebase_uid());
-- Indexes
CREATE INDEX IF NOT EXISTS bookmarked_searches_creator_id_created_time_idx

View File

@@ -0,0 +1,52 @@
CREATE TABLE IF NOT EXISTS compatibility_answers (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
creator_id TEXT NOT NULL,
explanation TEXT,
importance INTEGER NOT NULL,
multiple_choice INTEGER NOT NULL,
pref_choices INTEGER[] NOT NULL,
question_id BIGINT NOT NULL
);
ALTER TABLE compatibility_answers
ADD CONSTRAINT compatibility_answers_creator_id_fkey
FOREIGN KEY (creator_id)
REFERENCES users(id)
ON DELETE CASCADE;
-- Row Level Security
ALTER TABLE compatibility_answers ENABLE ROW LEVEL SECURITY;
ALTER TABLE compatibility_answers
ADD CONSTRAINT unique_question_creator
UNIQUE (question_id, creator_id);
-- Policies
DROP POLICY IF EXISTS "public read" ON compatibility_answers;
CREATE POLICY "public read" ON compatibility_answers
FOR SELECT USING (true);
-- DROP POLICY IF EXISTS "self delete" ON compatibility_answers;
-- CREATE POLICY "self delete" ON compatibility_answers
-- FOR DELETE USING (creator_id = firebase_uid());
--
-- DROP POLICY IF EXISTS "self insert" ON compatibility_answers;
-- CREATE POLICY "self insert" ON compatibility_answers
-- FOR INSERT WITH CHECK (creator_id = firebase_uid());
--
-- DROP POLICY IF EXISTS "self update" ON compatibility_answers;
-- CREATE POLICY "self update" ON compatibility_answers
-- FOR UPDATE USING (creator_id = firebase_uid());
-- Indexes
CREATE INDEX IF NOT EXISTS compatibility_answers_creator_id_created_time_idx
ON public.compatibility_answers (creator_id, created_time DESC);
CREATE UNIQUE INDEX IF NOT EXISTS compatibility_answers_question_creator_unique
ON public.compatibility_answers (question_id, creator_id);
CREATE INDEX IF NOT EXISTS compatibility_answers_question_id_idx
ON public.compatibility_answers (question_id);

View File

@@ -0,0 +1,45 @@
CREATE TABLE IF NOT EXISTS compatibility_answers_free (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
creator_id TEXT NOT NULL,
free_response TEXT,
integer INTEGER,
multiple_choice INTEGER,
question_id BIGINT NOT NULL
);
ALTER TABLE compatibility_answers_free
ADD CONSTRAINT compatibility_answers_free_creator_id_fkey
FOREIGN KEY (creator_id)
REFERENCES users(id)
ON DELETE CASCADE;
-- Row Level Security
ALTER TABLE compatibility_answers_free ENABLE ROW LEVEL SECURITY;
-- Policies
DROP POLICY IF EXISTS "public read" ON compatibility_answers_free;
CREATE POLICY "public read" ON compatibility_answers_free FOR SELECT USING (true);
DROP POLICY IF EXISTS "self delete" ON compatibility_answers_free;
CREATE POLICY "self delete" ON compatibility_answers_free FOR DELETE USING (creator_id = firebase_uid());
DROP POLICY IF EXISTS "self insert" ON compatibility_answers_free;
CREATE POLICY "self insert" ON compatibility_answers_free FOR INSERT WITH CHECK (creator_id = firebase_uid());
DROP POLICY IF EXISTS "self update" ON compatibility_answers_free;
CREATE POLICY "self update" ON compatibility_answers_free FOR UPDATE USING (creator_id = firebase_uid());
-- Indexes
DROP INDEX IF EXISTS compatibility_answers_free_creator_id_created_time_idx;
CREATE INDEX IF NOT EXISTS compatibility_answers_free_creator_id_created_time_idx
ON public.compatibility_answers_free USING btree (creator_id, created_time DESC);
DROP INDEX IF EXISTS compatibility_answers_free_question_creator_unique;
CREATE UNIQUE INDEX IF NOT EXISTS compatibility_answers_free_question_creator_unique
ON public.compatibility_answers_free USING btree (question_id, creator_id);
DROP INDEX IF EXISTS compatibility_answers_free_question_id_idx;
CREATE INDEX IF NOT EXISTS compatibility_answers_free_question_id_idx
ON public.compatibility_answers_free USING btree (question_id);

View File

@@ -0,0 +1,28 @@
CREATE TABLE IF NOT EXISTS compatibility_prompts (
answer_type TEXT DEFAULT 'free_response' NOT NULL,
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
creator_id TEXT,
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
importance_score NUMERIC DEFAULT 0 NOT NULL,
multiple_choice_options JSONB,
question TEXT NOT NULL
);
ALTER TABLE compatibility_prompts
ADD CONSTRAINT compatibility_prompts_creator_id_fkey
FOREIGN KEY (creator_id)
REFERENCES users(id)
ON DELETE SET NULL;
-- Row Level Security
ALTER TABLE compatibility_prompts ENABLE ROW LEVEL SECURITY;
-- Policies
DROP POLICY IF EXISTS "public read" ON compatibility_prompts;
CREATE POLICY "public read" ON compatibility_prompts
FOR SELECT USING (true);
-- Indexes
-- The primary key automatically creates a unique index on (id),
-- so the explicit index on id is redundant and removed.

View File

@@ -1,6 +1,6 @@
create table if not exists
contact (
id text default uuid_generate_v4 () not null,
id text default uuid_generate_v4 () not null primary key,
created_time timestamp with time zone default now(),
user_id text,
content jsonb

View File

@@ -2,15 +2,15 @@
create
or replace function public.to_jsonb (jsonb) returns jsonb language sql immutable parallel SAFE strict as $function$ select $1 $function$;
create
or replace function public.ts_to_millis (ts timestamp without time zone) returns bigint language sql immutable parallel SAFE as $function$
select extract(epoch from ts)::bigint * 1000
$function$;
create
or replace function public.ts_to_millis (ts timestamp with time zone) returns bigint language sql immutable parallel SAFE as $function$
select (extract(epoch from ts) * 1000)::bigint
$function$;
-- create
-- or replace function public.ts_to_millis (ts timestamp without time zone) returns bigint language sql immutable parallel SAFE as $function$
-- select extract(epoch from ts)::bigint * 1000
-- $function$;
--
-- create
-- or replace function public.ts_to_millis (ts timestamp with time zone) returns bigint language sql immutable parallel SAFE as $function$
-- select (extract(epoch from ts) * 1000)::bigint
-- $function$;
create
or replace function public.millis_to_ts (millis bigint) returns timestamp with time zone language sql immutable parallel SAFE as $function$

View File

@@ -1,34 +1,34 @@
create
or replace function public.get_compatibility_questions_with_answer_count () returns setof record language plpgsql as $function$
or replace function public.get_compatibility_prompts_with_answer_count () returns setof record language plpgsql as $function$
BEGIN
RETURN QUERY
SELECT
love_questions.*,
COUNT(love_compatibility_answers.question_id) as answer_count
compatibility_prompts.*,
COUNT(compatibility_answers.question_id) as answer_count
FROM
love_questions
compatibility_prompts
LEFT JOIN
love_compatibility_answers ON love_questions.id = love_compatibility_answers.question_id
WHERE love_questions.answer_type='compatibility_multiple_choice'
compatibility_answers ON compatibility_prompts.id = compatibility_answers.question_id
WHERE compatibility_prompts.answer_type='compatibility_multiple_choice'
GROUP BY
love_questions.id
compatibility_prompts.id
ORDER BY
answer_count DESC;
END;
$function$;
create
or replace function public.get_love_question_answers_and_profiles (p_question_id bigint) returns setof record language plpgsql as $function$
or replace function public.get_compatibility_answers_and_profiles (p_question_id bigint) returns setof record language plpgsql as $function$
BEGIN
RETURN QUERY
SELECT
love_answers.question_id,
love_answers.created_time,
love_answers.free_response,
love_answers.multiple_choice,
love_answers.integer,
compatibility_answers_free.question_id,
compatibility_answers_free.created_time,
compatibility_answers_free.free_response,
compatibility_answers_free.multiple_choice,
compatibility_answers_free.integer,
profiles.age,
profiles.gender,
profiles.city,
@@ -36,11 +36,11 @@ SELECT
FROM
profiles
JOIN
love_answers ON profiles.user_id = love_answers.creator_id
compatibility_answers_free ON profiles.user_id = compatibility_answers_free.creator_id
join
users on profiles.user_id = users.id
WHERE
love_answers.question_id = p_question_id
order by love_answers.created_time desc;
compatibility_answers_free.question_id = p_question_id
order by compatibility_answers_free.created_time desc;
END;
$function$;

View File

@@ -1,38 +0,0 @@
CREATE TABLE IF NOT EXISTS love_answers (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
creator_id TEXT NOT NULL,
free_response TEXT,
integer INTEGER,
multiple_choice INTEGER,
question_id BIGINT NOT NULL
);
-- Row Level Security
ALTER TABLE love_answers ENABLE ROW LEVEL SECURITY;
-- Policies
DROP POLICY IF EXISTS "public read" ON love_answers;
CREATE POLICY "public read" ON love_answers FOR SELECT USING (true);
DROP POLICY IF EXISTS "self delete" ON love_answers;
CREATE POLICY "self delete" ON love_answers FOR DELETE USING (creator_id = firebase_uid());
DROP POLICY IF EXISTS "self insert" ON love_answers;
CREATE POLICY "self insert" ON love_answers FOR INSERT WITH CHECK (creator_id = firebase_uid());
DROP POLICY IF EXISTS "self update" ON love_answers;
CREATE POLICY "self update" ON love_answers FOR UPDATE USING (creator_id = firebase_uid());
-- Indexes
DROP INDEX IF EXISTS love_answers_creator_id_created_time_idx;
CREATE INDEX IF NOT EXISTS love_answers_creator_id_created_time_idx
ON public.love_answers USING btree (creator_id, created_time DESC);
DROP INDEX IF EXISTS love_answers_question_creator_unique;
CREATE UNIQUE INDEX IF NOT EXISTS love_answers_question_creator_unique
ON public.love_answers USING btree (question_id, creator_id);
DROP INDEX IF EXISTS love_answers_question_id_idx;
CREATE INDEX IF NOT EXISTS love_answers_question_id_idx
ON public.love_answers USING btree (question_id);

View File

@@ -1,45 +0,0 @@
CREATE TABLE IF NOT EXISTS love_compatibility_answers (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
creator_id TEXT NOT NULL,
explanation TEXT,
importance INTEGER NOT NULL,
multiple_choice INTEGER NOT NULL,
pref_choices INTEGER[] NOT NULL,
question_id BIGINT NOT NULL
);
-- Row Level Security
ALTER TABLE love_compatibility_answers ENABLE ROW LEVEL SECURITY;
ALTER TABLE love_compatibility_answers
ADD CONSTRAINT unique_question_creator
UNIQUE (question_id, creator_id);
-- Policies
DROP POLICY IF EXISTS "public read" ON love_compatibility_answers;
CREATE POLICY "public read" ON love_compatibility_answers
FOR SELECT USING (true);
DROP POLICY IF EXISTS "self delete" ON love_compatibility_answers;
CREATE POLICY "self delete" ON love_compatibility_answers
FOR DELETE USING (creator_id = firebase_uid());
DROP POLICY IF EXISTS "self insert" ON love_compatibility_answers;
CREATE POLICY "self insert" ON love_compatibility_answers
FOR INSERT WITH CHECK (creator_id = firebase_uid());
DROP POLICY IF EXISTS "self update" ON love_compatibility_answers;
CREATE POLICY "self update" ON love_compatibility_answers
FOR UPDATE USING (creator_id = firebase_uid());
-- Indexes
CREATE INDEX IF NOT EXISTS love_compatibility_answers_creator_id_created_time_idx
ON public.love_compatibility_answers (creator_id, created_time DESC);
CREATE UNIQUE INDEX IF NOT EXISTS love_compatibility_answers_question_creator_unique
ON public.love_compatibility_answers (question_id, creator_id);
CREATE INDEX IF NOT EXISTS love_compatibility_answers_question_id_idx
ON public.love_compatibility_answers (question_id);

View File

@@ -1,22 +0,0 @@
CREATE TABLE IF NOT EXISTS love_likes (
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
creator_id TEXT NOT NULL,
like_id TEXT DEFAULT random_alphanumeric(12) NOT NULL,
target_id TEXT NOT NULL,
CONSTRAINT love_likes_pkey PRIMARY KEY (creator_id, like_id)
);
-- Row Level Security
ALTER TABLE love_likes ENABLE ROW LEVEL SECURITY;
-- Policies
DROP POLICY IF EXISTS "public read" ON love_likes;
CREATE POLICY "public read" ON love_likes
FOR SELECT USING (true);
-- Indexes
-- The primary key already creates a unique index on (creator_id, like_id)
-- so we do not recreate that. Additional indexes:
CREATE INDEX IF NOT EXISTS user_likes_target_id_raw
ON public.love_likes (target_id);

View File

@@ -1,21 +0,0 @@
CREATE TABLE IF NOT EXISTS love_questions (
answer_type TEXT DEFAULT 'free_response' NOT NULL,
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
creator_id TEXT NOT NULL,
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
importance_score NUMERIC DEFAULT 0 NOT NULL,
multiple_choice_options JSONB,
question TEXT NOT NULL
);
-- Row Level Security
ALTER TABLE love_questions ENABLE ROW LEVEL SECURITY;
-- Policies
DROP POLICY IF EXISTS "public read" ON love_questions;
CREATE POLICY "public read" ON love_questions
FOR ALL USING (true);
-- Indexes
-- The primary key automatically creates a unique index on (id),
-- so the explicit index on id is redundant and removed.

View File

@@ -1,25 +0,0 @@
CREATE TABLE IF NOT EXISTS love_ships (
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
creator_id TEXT NOT NULL,
ship_id TEXT DEFAULT random_alphanumeric(12) NOT NULL,
target1_id TEXT NOT NULL,
target2_id TEXT NOT NULL,
CONSTRAINT love_ships_pkey PRIMARY KEY (creator_id, ship_id)
);
-- Row Level Security
ALTER TABLE love_ships ENABLE ROW LEVEL SECURITY;
-- Policies
DROP POLICY IF EXISTS "public read" ON love_ships;
CREATE POLICY "public read" ON love_ships
FOR SELECT USING (true);
-- Indexes
-- Primary key automatically creates a unique index on (creator_id, ship_id), so no need to recreate it.
-- Keep additional indexes for query optimization:
DROP INDEX IF EXISTS love_ships_target1_id;
CREATE INDEX love_ships_target1_id ON public.love_ships USING btree (target1_id);
DROP INDEX IF EXISTS love_ships_target2_id;
CREATE INDEX love_ships_target2_id ON public.love_ships USING btree (target2_id);

View File

@@ -1,21 +0,0 @@
CREATE TABLE IF NOT EXISTS love_stars (
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
creator_id TEXT NOT NULL,
star_id TEXT DEFAULT random_alphanumeric(12) NOT NULL,
target_id TEXT NOT NULL,
CONSTRAINT love_stars_pkey PRIMARY KEY (creator_id, star_id)
);
-- Row Level Security
ALTER TABLE love_stars ENABLE ROW LEVEL SECURITY;
-- Policies
DROP POLICY IF EXISTS "public read" ON love_stars;
CREATE POLICY "public read" ON love_stars
FOR SELECT USING (true);
-- Indexes
-- The primary key already creates a unique index on (creator_id, star_id), so no need to recreate it.
DROP INDEX IF EXISTS love_stars_target_id_idx;
CREATE INDEX love_stars_target_id_idx ON public.love_stars USING btree (target_id);

View File

@@ -8,14 +8,14 @@ BEGIN;
\i backend/supabase/private_users.sql
\i backend/supabase/private_user_messages.sql
\i backend/supabase/private_user_seen_message_channels.sql
\i backend/supabase/love_answers.sql
\i backend/supabase/compatibility_answers_free.sql
\i backend/supabase/profile_comments.sql
\i backend/supabase/love_compatibility_answers.sql
\i backend/supabase/love_likes.sql
\i backend/supabase/love_questions.sql
\i backend/supabase/love_ships.sql
\i backend/supabase/love_stars.sql
\i backend/supabase/love_waitlist.sql
\i backend/supabase/compatibility_answers.sql
\i backend/supabase/profile_likes.sql
\i backend/supabase/compatibility_prompts.sql
\i backend/supabase/profile_ships.sql
\i backend/supabase/profile_stars.sql
\i backend/supabase/user_waitlist.sql
\i backend/supabase/user_events.sql
\i backend/supabase/user_notifications.sql
\i backend/supabase/functions_others.sql

View File

View File

@@ -10,6 +10,12 @@ CREATE TABLE IF NOT EXISTS private_user_message_channel_members (
);
-- Foreign Keys
ALTER TABLE private_user_message_channel_members
ADD CONSTRAINT private_user_message_channel_members_user_id_fkey
FOREIGN KEY (user_id)
REFERENCES users(id)
ON DELETE CASCADE;
DO $$
BEGIN
IF NOT EXISTS (
@@ -30,12 +36,10 @@ END$$;
ALTER TABLE private_user_message_channel_members ENABLE ROW LEVEL SECURITY;
-- Indexes
DROP INDEX IF EXISTS pumcm_members_idx;
CREATE INDEX pumcm_members_idx
CREATE INDEX IF NOT EXISTS pumcm_members_idx
ON public.private_user_message_channel_members
USING btree (channel_id, user_id);
DROP INDEX IF EXISTS unique_user_channel;
CREATE UNIQUE INDEX unique_user_channel
CREATE UNIQUE INDEX IF NOT EXISTS unique_user_channel
ON public.private_user_message_channel_members
USING btree (channel_id, user_id);

View File

@@ -9,12 +9,12 @@ CREATE TABLE IF NOT EXISTS private_user_message_channels (
-- Row Level Security
ALTER TABLE private_user_message_channels ENABLE ROW LEVEL SECURITY;
-- Policies
DROP POLICY IF EXISTS "public read" ON private_user_message_channels;
CREATE POLICY "public read" ON private_user_message_channels
FOR ALL
USING (true);
FOR SELECT USING (true);
-- Indexes
-- Removed redundant primary key index creation because PRIMARY KEY already creates a unique index on id

View File

@@ -1,10 +1,12 @@
CREATE TABLE IF NOT EXISTS private_user_messages (
channel_id BIGINT NOT NULL,
content JSONB NOT NULL,
content JSONB,
ciphertext text, -- base64
iv text, -- base64
tag text, -- base64 (GCM auth tag)
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
old_id BIGINT,
user_id TEXT NOT NULL,
user_id TEXT,
visibility TEXT DEFAULT 'private'::TEXT NOT NULL,
CONSTRAINT private_user_messages_channel_id_fkey
FOREIGN KEY (channel_id)
@@ -12,11 +14,16 @@ CREATE TABLE IF NOT EXISTS private_user_messages (
ON UPDATE CASCADE ON DELETE CASCADE
);
ALTER TABLE private_user_messages
ADD CONSTRAINT private_user_messages_user_id_fkey
FOREIGN KEY (user_id)
REFERENCES users(id)
ON DELETE SET NULL;
-- Row Level Security
ALTER TABLE private_user_messages ENABLE ROW LEVEL SECURITY;
-- Indexes
DROP INDEX IF EXISTS private_user_messages_channel_id_idx;
CREATE INDEX IF NOT EXISTS private_user_messages_channel_id_idx
ON public.private_user_messages USING btree (channel_id, created_time DESC);

View File

@@ -7,6 +7,12 @@ CREATE TABLE IF NOT EXISTS private_user_seen_message_channels (
);
-- Foreign Keys
ALTER TABLE private_user_seen_message_channels
ADD CONSTRAINT private_user_seen_message_channels_user_id_fkey
FOREIGN KEY (user_id)
REFERENCES users(id)
ON DELETE CASCADE;
DO $$
BEGIN
IF NOT EXISTS (
@@ -47,8 +53,6 @@ FOR SELECT
);
-- Indexes
DROP INDEX IF EXISTS user_seen_private_messages_created_time_desc_idx;
CREATE INDEX user_seen_private_messages_created_time_desc_idx
CREATE INDEX IF NOT EXISTS user_seen_private_messages_created_time_desc_idx
ON public.private_user_seen_message_channels
USING btree (user_id, channel_id, created_time DESC);

View File

@@ -4,6 +4,12 @@ CREATE TABLE IF NOT EXISTS private_users (
CONSTRAINT private_users_pkey PRIMARY KEY (id)
);
ALTER TABLE private_users
ADD CONSTRAINT private_users_id_fkey
FOREIGN KEY (id)
REFERENCES users(id)
ON DELETE CASCADE;
-- Row Level Security
ALTER TABLE private_users ENABLE ROW LEVEL SECURITY;

View File

@@ -11,12 +11,28 @@ CREATE TABLE IF NOT EXISTS profile_comments (
user_username TEXT NOT NULL
);
ALTER TABLE profile_comments
ADD CONSTRAINT profile_comments_user_id_fkey
FOREIGN KEY (user_id)
REFERENCES users(id)
ON DELETE CASCADE;
ALTER TABLE profile_comments
ADD CONSTRAINT profile_comments_on_user_id_fkey
FOREIGN KEY (on_user_id)
REFERENCES users(id)
ON DELETE CASCADE;
-- Row Level Security
ALTER TABLE profile_comments ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS "public read" ON profile_comments;
CREATE POLICY "public read" ON profile_comments
FOR SELECT USING (true);
-- Policies
DROP POLICY IF EXISTS "public read" ON profile_comments;
CREATE POLICY "public read" ON profile_comments FOR ALL USING (true);
CREATE POLICY "public read" ON profile_comments FOR SELECT USING (true);
-- Indexes
CREATE INDEX IF NOT EXISTS profile_comments_user_id_idx

View File

@@ -0,0 +1,28 @@
CREATE TABLE IF NOT EXISTS profile_likes (
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
creator_id TEXT NOT NULL,
like_id TEXT DEFAULT random_alphanumeric(12) NOT NULL,
target_id TEXT NOT NULL,
CONSTRAINT profile_likes_pkey PRIMARY KEY (creator_id, like_id)
);
ALTER TABLE profile_likes
ADD CONSTRAINT profile_likes_creator_id_fkey
FOREIGN KEY (creator_id)
REFERENCES users(id)
ON DELETE CASCADE;
-- Row Level Security
ALTER TABLE profile_likes ENABLE ROW LEVEL SECURITY;
-- Policies
DROP POLICY IF EXISTS "public read" ON profile_likes;
CREATE POLICY "public read" ON profile_likes
FOR SELECT USING (true);
-- Indexes
-- The primary key already creates a unique index on (creator_id, like_id)
-- so we do not recreate that. Additional indexes:
CREATE INDEX IF NOT EXISTS user_likes_target_id_raw
ON public.profile_likes (target_id);

View File

@@ -0,0 +1,28 @@
CREATE TABLE IF NOT EXISTS profile_ships (
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
creator_id TEXT NOT NULL,
ship_id TEXT DEFAULT random_alphanumeric(12) NOT NULL,
target1_id TEXT NOT NULL,
target2_id TEXT NOT NULL,
CONSTRAINT profile_ships_pkey PRIMARY KEY (creator_id, ship_id)
);
ALTER TABLE profile_ships
ADD CONSTRAINT profile_ships_creator_id_fkey
FOREIGN KEY (creator_id)
REFERENCES users(id)
ON DELETE CASCADE;
-- Row Level Security
ALTER TABLE profile_ships ENABLE ROW LEVEL SECURITY;
-- Policies
DROP POLICY IF EXISTS "public read" ON profile_ships;
CREATE POLICY "public read" ON profile_ships
FOR SELECT USING (true);
-- Indexes
-- Primary key automatically creates a unique index on (creator_id, ship_id), so no need to recreate it.
CREATE INDEX IF NOT EXISTS profile_ships_target1_id ON public.profile_ships USING btree (target1_id);
CREATE INDEX IF NOT EXISTS profile_ships_target2_id ON public.profile_ships USING btree (target2_id);

View File

@@ -0,0 +1,31 @@
CREATE TABLE IF NOT EXISTS profile_stars (
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
creator_id TEXT NOT NULL,
star_id TEXT DEFAULT random_alphanumeric(12) NOT NULL,
target_id TEXT NOT NULL,
CONSTRAINT profile_stars_pkey PRIMARY KEY (creator_id, star_id)
);
ALTER TABLE profile_stars
ADD CONSTRAINT profile_stars_creator_id_fkey
FOREIGN KEY (creator_id)
REFERENCES users(id)
ON DELETE CASCADE;
ALTER TABLE profile_stars
ADD CONSTRAINT profile_stars_target_id_fkey
FOREIGN KEY (target_id)
REFERENCES users(id)
ON DELETE CASCADE;
-- Row Level Security
ALTER TABLE profile_stars ENABLE ROW LEVEL SECURITY;
-- Policies
DROP POLICY IF EXISTS "public read" ON profile_stars;
CREATE POLICY "public read" ON profile_stars
FOR SELECT USING (true);
-- Indexes
-- The primary key already creates a unique index on (creator_id, star_id), so no need to recreate it.
CREATE INDEX IF NOT EXISTS profile_stars_target_id_idx ON public.profile_stars USING btree (target_id);

View File

@@ -27,7 +27,7 @@ CREATE TABLE IF NOT EXISTS profiles (
height_in_inches INTEGER,
id BIGINT GENERATED ALWAYS AS IDENTITY NOT NULL,
is_smoker BOOLEAN,
is_vegetarian_or_vegan BOOLEAN,
diet TEXT[],
last_modification_time TIMESTAMPTZ DEFAULT now() NOT NULL,
looking_for_matches BOOLEAN DEFAULT TRUE NOT NULL,
messaging_status TEXT DEFAULT 'open'::TEXT NOT NULL,
@@ -54,6 +54,13 @@ CREATE TABLE IF NOT EXISTS profiles (
CONSTRAINT profiles_pkey PRIMARY KEY (id)
);
ALTER TABLE profiles
ADD CONSTRAINT profiles_user_id_fkey
FOREIGN KEY (user_id)
REFERENCES users(id)
ON DELETE CASCADE;
-- Row Level Security
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
@@ -69,11 +76,9 @@ 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);
CREATE INDEX IF NOT EXISTS 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 UNIQUE INDEX IF NOT EXISTS 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);
@@ -82,13 +87,10 @@ CREATE INDEX IF NOT EXISTS idx_profiles_bio_length
ON profiles (bio_length);
-- Fastest general-purpose index
DROP INDEX IF EXISTS profiles_lat_lon_idx;
CREATE INDEX profiles_lat_lon_idx ON profiles (city_latitude, city_longitude);
CREATE INDEX IF NOT EXISTS profiles_lat_lon_idx ON profiles (city_latitude, city_longitude);
-- Optional additional index for large tables / clustered inserts
DROP INDEX IF EXISTS profiles_lat_lon_brin_idx;
CREATE INDEX profiles_lat_lon_brin_idx ON profiles USING BRIN (city_latitude, city_longitude) WITH (pages_per_range = 32);
CREATE INDEX IF NOT EXISTS profiles_lat_lon_brin_idx ON profiles USING BRIN (city_latitude, city_longitude) WITH (pages_per_range = 32);
-- Functions and Triggers

View File

@@ -0,0 +1,17 @@
create table push_subscriptions (
id serial primary key,
user_id TEXT references users(id) on delete cascade,
endpoint text not null unique,
keys jsonb not null,
created_at timestamptz default now()
);
-- Row Level Security
ALTER TABLE push_subscriptions ENABLE ROW LEVEL SECURITY;
-- Indexes
CREATE INDEX IF not exists user_id_idx ON push_subscriptions (user_id);
CREATE INDEX IF not exists endpoint_idx ON push_subscriptions (endpoint);
CREATE INDEX IF not exists endpoint_user_id_idx ON push_subscriptions (endpoint, user_id);

View File

@@ -5,6 +5,13 @@ CREATE TABLE IF NOT EXISTS user_notifications (
CONSTRAINT user_notifications_pkey PRIMARY KEY (notification_id, user_id)
);
ALTER TABLE user_notifications
ADD CONSTRAINT user_notifications_user_id_fkey
FOREIGN KEY (user_id)
REFERENCES users(id)
ON DELETE CASCADE;
-- Row Level Security
ALTER TABLE user_notifications ENABLE ROW LEVEL SECURITY;

View File

@@ -1,15 +1,15 @@
CREATE TABLE IF NOT EXISTS love_waitlist (
CREATE TABLE IF NOT EXISTS user_waitlist (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
email TEXT NOT NULL
);
-- Row Level Security
ALTER TABLE love_waitlist ENABLE ROW LEVEL SECURITY;
ALTER TABLE user_waitlist ENABLE ROW LEVEL SECURITY;
-- Policies
DROP POLICY IF EXISTS "anon insert" ON love_waitlist;
CREATE POLICY "anon insert" ON love_waitlist
DROP POLICY IF EXISTS "anon insert" ON user_waitlist;
CREATE POLICY "anon insert" ON user_waitlist
FOR INSERT WITH CHECK (true);
-- Indexes

View File

@@ -18,20 +18,16 @@ ALTER TABLE users ENABLE ROW LEVEL SECURITY;
-- Policies
DROP POLICY IF EXISTS "public read" ON users;
CREATE POLICY "public read" ON users
FOR SELECT
USING (true);
-- Indexes
DROP INDEX IF EXISTS user_username_idx;
CREATE INDEX user_username_idx ON public.users USING btree (username);
CREATE INDEX IF NOT EXISTS user_username_idx ON public.users USING btree (username);
DROP INDEX IF EXISTS users_created_time;
CREATE INDEX users_created_time ON public.users USING btree (created_time DESC);
CREATE INDEX IF NOT EXISTS users_created_time ON public.users USING btree (created_time DESC);
DROP INDEX IF EXISTS users_name_idx;
CREATE INDEX users_name_idx ON public.users USING btree (name);
CREATE INDEX IF NOT EXISTS users_name_idx ON public.users USING btree (name);
-- Remove these if you trust PRIMARY KEY auto-index:
-- DROP INDEX IF EXISTS users_pkey;

View File

@@ -1 +0,0 @@

View File

@@ -21,23 +21,18 @@ ALTER TABLE vote_results ENABLE ROW LEVEL SECURITY;
-- Policies
DROP POLICY IF EXISTS "public read" ON vote_results;
CREATE POLICY "public read" ON vote_results
FOR ALL USING (true);
FOR SELECT USING (true);
-- Indexes
DROP INDEX IF EXISTS user_id_idx;
CREATE INDEX user_id_idx ON vote_results (user_id);
CREATE INDEX IF NOT EXISTS user_id_idx ON vote_results (user_id);
DROP INDEX IF EXISTS vote_id_idx;
CREATE INDEX vote_id_idx ON vote_results (vote_id);
CREATE INDEX IF NOT EXISTS vote_id_idx ON vote_results (vote_id);
DROP INDEX IF EXISTS idx_vote_results_vote_choice;
CREATE INDEX idx_vote_results_vote_choice ON vote_results (vote_id, choice);
CREATE INDEX IF NOT EXISTS idx_vote_results_vote_choice ON vote_results (vote_id, choice);
DROP INDEX IF EXISTS idx_vote_results_vote_choice_priority;
CREATE INDEX idx_vote_results_vote_choice_priority ON vote_results (vote_id, choice, priority);
CREATE INDEX IF NOT EXISTS idx_vote_results_vote_choice_priority ON vote_results (vote_id, choice, priority);
DROP INDEX IF EXISTS idx_votes_created_time;
CREATE INDEX idx_votes_created_time ON votes (created_time DESC);
CREATE INDEX IF NOT EXISTS idx_votes_created_time ON votes (created_time DESC);
drop function if exists get_votes_with_results;

View File

@@ -17,11 +17,9 @@ ALTER TABLE votes ENABLE ROW LEVEL SECURITY;
-- Policies
DROP POLICY IF EXISTS "public read" ON votes;
CREATE POLICY "public read" ON votes
FOR ALL USING (true);
FOR SELECT USING (true);
-- Indexes
DROP INDEX IF EXISTS creator_id_idx;
CREATE INDEX creator_id_idx ON votes (creator_id);
CREATE INDEX IF NOT EXISTS creator_id_idx ON votes (creator_id);
DROP INDEX IF EXISTS idx_votes_created_time;
CREATE INDEX idx_votes_created_time ON votes (created_time DESC);
CREATE INDEX IF NOT EXISTS idx_votes_created_time ON votes (created_time DESC);

View File

@@ -22,7 +22,7 @@
"dayjs": "1.11.4",
"lodash": "4.17.21",
"string-similarity": "4.0.4",
"zod": "3.21.4"
"zod": "3.22.3"
},
"devDependencies": {
"@types/jest": "29.2.4",

View File

@@ -1,18 +1,13 @@
import {
contentSchema,
combinedLoveUsersSchema,
baseProfilesSchema,
arraybeSchema,
} from 'common/api/zod-types'
import {arraybeSchema, baseProfilesSchema, combinedProfileSchema, contentSchema, zBoolean,} from 'common/api/zod-types'
import {PrivateChatMessage} from 'common/chat-message'
import {CompatibilityScore} from 'common/love/compatibility-score'
import {MAX_COMPATIBILITY_QUESTION_LENGTH} from 'common/love/constants'
import {Profile, ProfileRow} from 'common/love/profile'
import {CompatibilityScore} from 'common/profiles/compatibility-score'
import {MAX_COMPATIBILITY_QUESTION_LENGTH} from 'common/profiles/constants'
import {Profile, ProfileRow} from 'common/profiles/profile'
import {Row} from 'common/supabase/utils'
import {PrivateUser, User} from 'common/user'
import {z} from 'zod'
import {LikeData, ShipData} from './love-types'
import {DisplayUser, FullUser} from './user-types'
import {LikeData, ShipData} from './profile-types'
import {FullUser} from './user-types'
import {PrivateMessageChannel} from 'common/supabase/private-messages'
import {Notification} from 'common/notifications'
import {arrify} from 'common/util/array'
@@ -36,6 +31,10 @@ type APIGenericSchema = {
returns?: Record<string, any>
// Cache-Control header. like, 'max-age=60'
cache?: string
// Description of the endpoint
summary?: string
// Tag for grouping endpoints in documentation
tag?: string
}
let _apiTypeCheck: { [x: string]: APIGenericSchema }
@@ -47,6 +46,8 @@ export const API = (_apiTypeCheck = {
rateLimited: false,
props: z.object({}),
returns: {} as { message: 'Server is working.'; uid?: string },
summary: 'Check whether the API server is running',
tag: 'General',
},
'get-supabase-token': {
method: 'GET',
@@ -54,24 +55,69 @@ export const API = (_apiTypeCheck = {
rateLimited: false,
props: z.object({}),
returns: {} as { jwt: string },
summary: 'Return a Supabase JWT for authenticated clients',
tag: 'Tokens',
},
'mark-all-notifs-read': {
method: 'POST',
authed: true,
rateLimited: false,
props: z.object({}),
summary: 'Mark all user notifications as read',
tag: 'Notifications',
},
// 'user/:username': {
// method: 'GET',
// authed: false,
// rateLimited: false,
// cache: DEFAULT_CACHE_STRATEGY,
// returns: {} as FullUser,
// props: z.object({username: z.string()}).strict(),
// summary: 'Get full public profile by username',
// },
// 'user/:username/lite': {
// method: 'GET',
// authed: false,
// rateLimited: false,
// cache: DEFAULT_CACHE_STRATEGY,
// returns: {} as DisplayUser,
// props: z.object({username: z.string()}).strict(),
// summary: 'Get lightweight public profile by username',
// },
'user/by-id/:id': {
method: 'GET',
authed: true,
rateLimited: true,
cache: DEFAULT_CACHE_STRATEGY,
returns: {} as FullUser,
props: z.object({id: z.string()}).strict(),
summary: 'Get full profile by user ID',
tag: 'Users',
},
// 'user/by-id/:id/lite': {
// method: 'GET',
// authed: false,
// rateLimited: false,
// cache: DEFAULT_CACHE_STRATEGY,
// returns: {} as DisplayUser,
// props: z.object({id: z.string()}).strict(),
// summary: 'Get lightweight profile by user ID',
// },
'user/by-id/:id/block': {
method: 'POST',
authed: true,
rateLimited: false,
props: z.object({id: z.string()}).strict(),
summary: 'Block a user by their ID',
tag: 'Users',
},
'user/by-id/:id/unblock': {
method: 'POST',
authed: true,
rateLimited: false,
props: z.object({id: z.string()}).strict(),
summary: 'Unblock a user by their ID',
tag: 'Users',
},
'ban-user': {
method: 'POST',
@@ -83,9 +129,10 @@ export const API = (_apiTypeCheck = {
unban: z.boolean().optional(),
})
.strict(),
summary: 'Ban or unban a user',
tag: 'Admin',
},
'create-user': {
// TODO rest
method: 'POST',
authed: true,
rateLimited: true,
@@ -96,6 +143,8 @@ export const API = (_apiTypeCheck = {
adminToken: z.string().optional(),
})
.strict(),
summary: 'Create a new user (admin or onboarding flow)',
tag: 'Users',
},
'create-profile': {
method: 'POST',
@@ -103,6 +152,8 @@ export const API = (_apiTypeCheck = {
rateLimited: true,
returns: {} as Row<'profiles'>,
props: baseProfilesSchema,
summary: 'Create a new profile for the authenticated user',
tag: 'Profiles',
},
report: {
method: 'POST',
@@ -119,6 +170,8 @@ export const API = (_apiTypeCheck = {
})
.strict(),
returns: {} as any,
summary: 'Submit a report for content or a user',
tag: 'Moderation',
},
me: {
method: 'GET',
@@ -127,6 +180,8 @@ export const API = (_apiTypeCheck = {
cache: DEFAULT_CACHE_STRATEGY,
props: z.object({}),
returns: {} as FullUser,
summary: 'Get the authenticated user full data',
tag: 'Users',
},
'me/update': {
method: 'POST',
@@ -155,13 +210,17 @@ export const API = (_apiTypeCheck = {
discordHandle: z.string().optional(),
}),
returns: {} as FullUser,
summary: 'Update authenticated user profile and settings',
tag: 'Users',
},
'update-profile': {
method: 'POST',
authed: true,
rateLimited: true,
props: combinedLoveUsersSchema.partial(),
props: combinedProfileSchema.partial(),
returns: {} as ProfileRow,
summary: 'Update profile fields for the authenticated user',
tag: 'Profiles',
},
'update-notif-settings': {
method: 'POST',
@@ -172,6 +231,8 @@ export const API = (_apiTypeCheck = {
medium: z.enum(['email', 'browser', 'mobile']),
enabled: z.boolean(),
}),
summary: 'Update a notification preference for the user',
tag: 'Notifications',
},
'me/delete': {
method: 'POST',
@@ -180,6 +241,8 @@ export const API = (_apiTypeCheck = {
props: z.object({
username: z.string(), // just so you're sure
}),
summary: 'Delete the authenticated user account',
tag: 'Users',
},
'me/private': {
method: 'GET',
@@ -187,38 +250,8 @@ export const API = (_apiTypeCheck = {
rateLimited: false,
props: z.object({}),
returns: {} as PrivateUser,
},
'user/:username': {
method: 'GET',
authed: false,
rateLimited: false,
cache: DEFAULT_CACHE_STRATEGY,
returns: {} as FullUser,
props: z.object({username: z.string()}).strict(),
},
'user/:username/lite': {
method: 'GET',
authed: false,
rateLimited: false,
cache: DEFAULT_CACHE_STRATEGY,
returns: {} as DisplayUser,
props: z.object({username: z.string()}).strict(),
},
'user/by-id/:id': {
method: 'GET',
authed: false,
rateLimited: false,
cache: DEFAULT_CACHE_STRATEGY,
returns: {} as FullUser,
props: z.object({id: z.string()}).strict(),
},
'user/by-id/:id/lite': {
method: 'GET',
authed: false,
rateLimited: false,
cache: DEFAULT_CACHE_STRATEGY,
returns: {} as DisplayUser,
props: z.object({id: z.string()}).strict(),
summary: 'Get private user data for the authenticated user',
tag: 'Users',
},
'search-users': {
method: 'GET',
@@ -233,6 +266,8 @@ export const API = (_apiTypeCheck = {
page: z.coerce.number().gte(0).default(0),
})
.strict(),
summary: 'Search users by term with pagination',
tag: 'Users',
},
'compatible-profiles': {
method: 'GET',
@@ -246,6 +281,8 @@ export const API = (_apiTypeCheck = {
[userId: string]: CompatibilityScore
}
},
summary: 'Find profiles compatible with a given user',
tag: 'Profiles',
},
'remove-pinned-photo': {
method: 'POST',
@@ -257,6 +294,8 @@ export const API = (_apiTypeCheck = {
userId: z.string(),
})
.strict(),
summary: 'Remove the pinned photo from a profile',
tag: 'Profiles',
},
'get-compatibility-questions': {
method: 'GET',
@@ -265,11 +304,13 @@ export const API = (_apiTypeCheck = {
props: z.object({}),
returns: {} as {
status: 'success'
questions: (Row<'love_questions'> & {
questions: (Row<'compatibility_prompts'> & {
answer_count: number
score: number
})[]
},
summary: 'Retrieve compatibility questions and stats',
tag: 'Compatibility',
},
'like-profile': {
method: 'POST',
@@ -282,6 +323,8 @@ export const API = (_apiTypeCheck = {
returns: {} as {
status: 'success'
},
summary: 'Like or unlike a profile',
tag: 'Profiles',
},
'ship-profiles': {
method: 'POST',
@@ -295,6 +338,8 @@ export const API = (_apiTypeCheck = {
returns: {} as {
status: 'success'
},
summary: 'Create or remove a ship between two profiles',
tag: 'Profiles',
},
'get-likes-and-ships': {
method: 'GET',
@@ -311,6 +356,8 @@ export const API = (_apiTypeCheck = {
likesGiven: LikeData[]
ships: ShipData[]
},
summary: 'Fetch likes and ships for a user',
tag: 'Profiles',
},
'has-free-like': {
method: 'GET',
@@ -321,6 +368,8 @@ export const API = (_apiTypeCheck = {
status: 'success'
hasFreeLike: boolean
},
summary: 'Check whether the user has a free like available',
tag: 'Profiles',
},
'star-profile': {
method: 'POST',
@@ -333,6 +382,8 @@ export const API = (_apiTypeCheck = {
returns: {} as {
status: 'success'
},
summary: 'Star or unstar a profile',
tag: 'Profiles',
},
'get-profiles': {
method: 'GET',
@@ -345,15 +396,20 @@ export const API = (_apiTypeCheck = {
// Search and filter parameters
name: z.string().optional(),
genders: arraybeSchema.optional(),
education_levels: arraybeSchema.optional(),
pref_gender: arraybeSchema.optional(),
pref_age_min: z.coerce.number().optional(),
pref_age_max: z.coerce.number().optional(),
drinks_min: z.coerce.number().optional(),
drinks_max: z.coerce.number().optional(),
pref_relation_styles: arraybeSchema.optional(),
pref_romantic_styles: arraybeSchema.optional(),
diet: arraybeSchema.optional(),
political_beliefs: arraybeSchema.optional(),
wants_kids_strength: z.coerce.number().optional(),
has_kids: z.coerce.number().optional(),
is_smoker: z.coerce.boolean().optional(),
shortBio: z.coerce.boolean().optional(),
is_smoker: zBoolean.optional().optional(),
shortBio: zBoolean.optional().optional(),
geodbCityIds: arraybeSchema.optional(),
lat: z.coerce.number().optional(),
lon: z.coerce.number().optional(),
@@ -369,16 +425,8 @@ export const API = (_apiTypeCheck = {
status: 'success' | 'fail'
profiles: Profile[]
},
},
'get-profile-answers': {
method: 'GET',
authed: true,
rateLimited: true,
props: z.object({userId: z.string()}).strict(),
returns: {} as {
status: 'success'
answers: Row<'love_compatibility_answers'>[]
},
summary: 'List profiles with filters, pagination and ordering',
tag: 'Profiles',
},
'create-comment': {
method: 'POST',
@@ -390,6 +438,8 @@ export const API = (_apiTypeCheck = {
replyToCommentId: z.string().optional(),
}),
returns: {} as any,
summary: 'Create a comment or reply',
tag: 'Profiles',
},
'hide-comment': {
method: 'POST',
@@ -400,6 +450,8 @@ export const API = (_apiTypeCheck = {
hide: z.boolean(),
}),
returns: {} as any,
summary: 'Hide or unhide a comment',
tag: 'Profiles',
},
'get-channel-memberships': {
method: 'GET',
@@ -415,6 +467,8 @@ export const API = (_apiTypeCheck = {
channels: [] as PrivateMessageChannel[],
memberIdsByChannelId: {} as { [channelId: string]: string[] },
},
summary: 'List private message channel memberships',
tag: 'Messages',
},
'get-channel-messages': {
method: 'GET',
@@ -426,6 +480,8 @@ export const API = (_apiTypeCheck = {
id: z.coerce.number().optional(),
}),
returns: [] as PrivateChatMessage[],
summary: 'Retrieve messages for a private channel',
tag: 'Messages',
},
'get-channel-seen-time': {
method: 'GET',
@@ -438,6 +494,8 @@ export const API = (_apiTypeCheck = {
.transform(arrify),
}),
returns: [] as [number, string][],
summary: 'Get last seen times for one or more channels',
tag: 'Messages',
},
'set-channel-seen-time': {
method: 'POST',
@@ -446,12 +504,16 @@ export const API = (_apiTypeCheck = {
props: z.object({
channelId: z.coerce.number(),
}),
summary: 'Set last seen time for a channel',
tag: 'Messages',
},
'set-last-online-time': {
method: 'POST',
authed: true,
rateLimited: false,
props: z.object({}),
summary: 'Update the user last online timestamp',
tag: 'Users',
},
'get-notifications': {
method: 'GET',
@@ -464,6 +526,8 @@ export const API = (_apiTypeCheck = {
limit: z.coerce.number().gte(0).lte(1000).default(100),
})
.strict(),
summary: 'Fetch notifications for the authenticated user',
tag: 'Notifications',
},
'create-private-user-message': {
method: 'POST',
@@ -474,6 +538,8 @@ export const API = (_apiTypeCheck = {
content: contentSchema,
channelId: z.number(),
}),
summary: 'Send a message in a private channel',
tag: 'Messages',
},
'create-private-user-message-channel': {
method: 'POST',
@@ -483,6 +549,8 @@ export const API = (_apiTypeCheck = {
props: z.object({
userIds: z.array(z.string()),
}),
summary: 'Create a new private message channel between users',
tag: 'Messages',
},
'update-private-user-message-channel': {
method: 'POST',
@@ -493,6 +561,8 @@ export const API = (_apiTypeCheck = {
channelId: z.number(),
notifyAfterTime: z.number(),
}),
summary: 'Update settings for a private message channel',
tag: 'Messages',
},
'leave-private-user-message-channel': {
method: 'POST',
@@ -502,6 +572,8 @@ export const API = (_apiTypeCheck = {
props: z.object({
channelId: z.number(),
}),
summary: 'Leave a private message channel',
tag: 'Messages',
},
'create-compatibility-question': {
method: 'POST',
@@ -512,6 +584,37 @@ export const API = (_apiTypeCheck = {
question: z.string().min(1).max(MAX_COMPATIBILITY_QUESTION_LENGTH),
options: z.record(z.string(), z.number()),
}),
summary: 'Create a new compatibility question with options',
tag: 'Compatibility',
},
'set-compatibility-answer': {
method: 'POST',
authed: true,
rateLimited: true,
returns: {} as Row<'compatibility_answers'>,
props: z
.object({
questionId: z.number(),
multipleChoice: z.number(),
prefChoices: z.array(z.number()),
importance: z.number(),
explanation: z.string().nullable().optional(),
})
.strict(),
summary: 'Submit or update a compatibility answer',
tag: 'Compatibility',
},
'get-profile-answers': {
method: 'GET',
authed: true,
rateLimited: true,
props: z.object({userId: z.string()}).strict(),
returns: {} as {
status: 'success'
answers: Row<'compatibility_answers'>[]
},
summary: 'Get compatibility answers for a profile',
tag: 'Compatibility',
},
'create-vote': {
method: 'POST',
@@ -523,6 +626,8 @@ export const API = (_apiTypeCheck = {
isAnonymous: z.boolean(),
description: contentSchema,
}),
summary: 'Create a new vote/poll',
tag: 'Votes',
},
'vote': {
method: 'POST',
@@ -534,6 +639,8 @@ export const API = (_apiTypeCheck = {
priority: z.number(),
choice: z.enum(['for', 'abstain', 'against']),
}),
summary: 'Cast a vote on an existing poll',
tag: 'Votes',
},
'search-location': {
method: 'POST',
@@ -544,6 +651,8 @@ export const API = (_apiTypeCheck = {
term: z.string(),
limit: z.number().optional(),
}),
summary: 'Search for a location by text',
tag: 'Locations',
},
'search-near-city': {
method: 'POST',
@@ -554,6 +663,8 @@ export const API = (_apiTypeCheck = {
cityId: z.string(),
radius: z.number().min(1).max(500),
}),
summary: 'Find places near a GeoDB city ID within a radius',
tag: 'Locations',
},
'contact': {
method: 'POST',
@@ -564,6 +675,8 @@ export const API = (_apiTypeCheck = {
content: contentSchema,
userId: z.string().optional(),
}),
summary: 'Send a contact/support message',
tag: 'Contact',
},
'get-messages-count': {
method: 'GET',
@@ -571,6 +684,44 @@ export const API = (_apiTypeCheck = {
rateLimited: false,
props: z.object({}),
returns: {} as { count: number },
summary: 'Get the total number of messages (public endpoint)',
tag: 'Messages',
},
'save-subscription': {
method: 'POST',
authed: true,
rateLimited: true,
returns: {} as any,
props: z.object({
subscription: z.record(z.any())
}),
summary: 'Save a push/browser subscription for the user',
tag: 'Notifications',
},
'create-bookmarked-search': {
method: 'POST',
authed: true,
rateLimited: true,
returns: {} as Row<'bookmarked_searches'>,
props: z
.object({
search_filters: z.any().optional(),
location: z.any().optional(),
search_name: z.string().nullable().optional(),
}),
summary: 'Create a bookmarked search for quick reuse',
tag: 'Searches',
},
'delete-bookmarked-search': {
method: 'POST',
authed: true,
rateLimited: true,
returns: {} as any,
props: z.object({
id: z.number(),
}),
summary: 'Delete a bookmarked search by ID',
tag: 'Searches',
},
} as const)

View File

@@ -1,4 +1,4 @@
import { ENV_CONFIG, MOD_IDS } from 'common/envs/constants'
import { ENV_CONFIG, MOD_USERNAMES } from 'common/envs/constants'
import { User } from 'common/user'
import { removeUndefinedProps } from 'common/util/object'
@@ -22,6 +22,6 @@ export function toUserAPIResponse(user: User): FullUser {
...user,
url: `https://${ENV_CONFIG.domain}/${user.username}`,
isAdmin: ENV_CONFIG.adminIds.includes(user.id),
isTrustworthy: MOD_IDS.includes(user.id),
isTrustworthy: MOD_USERNAMES.includes(user.username),
})
}

View File

@@ -42,6 +42,10 @@ const genderType = z.string()
// ])
const genderTypes = z.array(genderType)
export const zBoolean = z
.union([z.boolean(), z.string()])
.transform((val) => val === true || val === "true");
export const baseProfilesSchema = z.object({
// Required fields
age: z.number().min(18).max(100).optional(),
@@ -51,7 +55,7 @@ export const baseProfilesSchema = z.object({
pref_age_max: z.number().min(18).max(100).optional(),
pref_relation_styles: z.array(z.string()),
wants_kids_strength: z.number(),
looking_for_matches: z.boolean(),
looking_for_matches: zBoolean,
photo_urls: z.array(z.string()),
visibility: z.union([z.literal('public'), z.literal('member')]),
@@ -76,23 +80,25 @@ const optionalProfilesSchema = z.object({
ethnicity: z.array(z.string()).optional(),
born_in_location: z.string().optional(),
height_in_inches: z.number().optional(),
has_pets: z.boolean().optional(),
has_pets: zBoolean.optional().optional(),
education_level: z.string().optional(),
is_smoker: z.boolean().optional(),
is_smoker: zBoolean.optional().optional(),
drinks_per_month: z.number().min(0).optional(),
is_vegetarian_or_vegan: z.boolean().optional(),
diet: z.array(z.string()).optional(),
has_kids: z.number().min(0).optional(),
university: z.string().optional(),
occupation_title: z.string().optional(),
occupation: z.string().optional(),
company: z.string().optional(),
comments_enabled: z.boolean().optional(),
comments_enabled: zBoolean.optional().optional(),
website: z.string().optional(),
bio: contentSchema.optional().nullable(),
twitter: z.string().optional(),
avatar_url: z.string().optional(),
pref_romantic_styles: z.array(z.string()),
drinks_min: z.number().min(0).optional(),
drinks_max: z.number().min(0).optional(),
})
export const combinedLoveUsersSchema =
export const combinedProfileSchema =
baseProfilesSchema.merge(optionalProfilesSchema)

View File

@@ -1,5 +1,5 @@
export const MAX_INT = 99999
export const MIN_INT = -MAX_INT
export const MIN_INT = Number.MIN_SAFE_INTEGER
export const MAX_INT = Number.MAX_SAFE_INTEGER
export const supportEmail = 'hello@compassmeet.com';
// export const marketingEmail = 'hello@compassmeet.com';
@@ -8,6 +8,7 @@ export const githubRepo = "https://github.com/CompassConnections/Compass";
export const githubIssues = `${githubRepo}/issues`
export const paypalLink = "https://www.paypal.com/paypalme/CompassConnections"
export const openCollectiveLink = "https://opencollective.com/compass-connection"
export const patreonLink = "https://patreon.com/CompassMeet"
export const discordLink = "https://discord.gg/8Vd7jzqjun"
export const stoatLink = "https://stt.gg/YKQp81yA"

View File

@@ -5,6 +5,20 @@ import {isProd} from "common/envs/is-prod";
export const MAX_DESCRIPTION_LENGTH = 100000
export const MAX_ANSWER_LENGTH = 240
export const LOCAL_WEB_DOMAIN = 'localhost:3000';
export const LOCAL_BACKEND_DOMAIN = 'localhost:8088';
export const IS_GOOGLE_CLOUD = !!process.env.GOOGLE_CLOUD_PROJECT
export const IS_VERCEL = !!process.env.NEXT_PUBLIC_VERCEL
export const IS_DEPLOYED = IS_GOOGLE_CLOUD || IS_VERCEL
export const IS_LOCAL = !IS_DEPLOYED
export const HOSTING_ENV = IS_GOOGLE_CLOUD ? 'Google Cloud' : IS_VERCEL ? 'Vercel' : IS_LOCAL ? 'local' : 'unknown'
if (IS_LOCAL && !process.env.ENVIRONMENT && !process.env.NEXT_PUBLIC_FIREBASE_ENV) {
console.warn("No ENVIRONMENT set, defaulting to DEV")
process.env.ENVIRONMENT = 'DEV'
}
export const ENV_CONFIG = isProd() ? PROD_CONFIG : DEV_CONFIG
export function isAdminId(id: string) {
@@ -12,20 +26,13 @@ export function isAdminId(id: string) {
}
export function isModId(id: string) {
return MOD_IDS.includes(id)
return MOD_USERNAMES.includes(id)
}
export const ENV = isProd() ? 'prod' : 'dev'
export const IS_PROD = ENV === 'prod'
export const IS_DEV = ENV === 'dev'
export const LOCAL_WEB_DOMAIN = 'localhost:3000';
export const LOCAL_BACKEND_DOMAIN = 'localhost:8088';
export const IS_GOOGLE_CLOUD = !!process.env.GOOGLE_CLOUD_PROJECT
export const IS_VERCEL = !!process.env.NEXT_PUBLIC_VERCEL
export const IS_LOCAL = !IS_GOOGLE_CLOUD && !IS_VERCEL
export const HOSTING_ENV = IS_GOOGLE_CLOUD ? 'Google Cloud' : IS_VERCEL ? 'Vercel' : IS_LOCAL ? 'local' : 'unknown'
console.debug(`Running in ${HOSTING_ENV} (${ENV})`,);
// class MissingKeyError implements Error {
@@ -57,8 +64,8 @@ export const AUTH_COOKIE_NAME = `FBUSER_${PROJECT_ID.toUpperCase().replace(
'_'
)}`
export const MOD_IDS = [
'...',
export const MOD_USERNAMES = [
'Martin',
]
export const VERIFIED_USERNAMES = [
@@ -86,6 +93,7 @@ export const RESERVED_PATHS = [
'chat',
'chats',
'common',
'compatibility',
'confirm-email',
'contact',
'contacts',
@@ -109,7 +117,7 @@ export const RESERVED_PATHS = [
'links',
'live',
'login',
'love-questions',
'questions',
'manifest',
'market',
'markets',

View File

@@ -5,6 +5,7 @@ export const DEV_CONFIG: EnvConfig = {
domain: 'dev.compassmeet.com',
backendDomain: 'api.dev.compassmeet.com',
supabaseInstanceId: 'zbspxezubpzxmuxciurg',
dbEncryptionKey: 'MIWD5LuZ6KoRNChcmOn762MKJRHhH4rLUGYse7+GeS4=',
supabasePwd: 'ZTNlifGKofSKhu8c', // For database write access (dev). A 16-character password with digits and letters.
supabaseAnonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Inpic3B4ZXp1YnB6eG11eGNpdXJnIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTc2ODM0MTMsImV4cCI6MjA3MzI1OTQxM30.ZkM7zlawP8Nke0T3KJrqpOQ4DzqPaXTaJXLC2WU8Y7c',
googleApplicationCredentials: 'googleApplicationCredentials-dev.json',

View File

@@ -1,6 +1,7 @@
export type EnvConfig = {
domain: string
firebaseConfig: FirebaseConfig
dbEncryptionKey: string
supabaseInstanceId: string
supabaseAnonKey: string
supabasePwd?: string
@@ -38,6 +39,7 @@ export const PROD_CONFIG: EnvConfig = {
supabaseInstanceId: 'ltzepxnhhnrnvovqblfr',
supabaseAnonKey: '',
supabasePwd: '',
dbEncryptionKey: '',
googleApplicationCredentials: undefined,
firebaseConfig: {
apiKey: '',
@@ -63,6 +65,7 @@ export const refreshConfig = () => {
PROD_CONFIG.supabasePwd = process.env.SUPABASE_DB_PASSWORD || ''
PROD_CONFIG.googleApplicationCredentials = process.env.GOOGLE_APPLICATION_CREDENTIALS
PROD_CONFIG.firebaseConfig.apiKey = process.env.NEXT_PUBLIC_FIREBASE_API_KEY || ''
PROD_CONFIG.dbEncryptionKey = process.env.DB_ENC_MASTER_KEY_BASE64 || ''
}
refreshConfig()

View File

@@ -1,4 +1,4 @@
import {Profile, ProfileRow} from "common/love/profile";
import {Profile, ProfileRow} from "common/profiles/profile";
import {cloneDeep} from "lodash";
import {filterDefined} from "common/util/array";
@@ -15,12 +15,18 @@ export type FilterFields = {
lon: number | null
radius: number | null
genders: string[]
education_levels: string[]
name: string | undefined
shortBio: boolean | undefined
drinks_min: number | undefined
drinks_max: number | undefined
} & Pick<
ProfileRow,
| 'wants_kids_strength'
| 'pref_relation_styles'
| 'pref_romantic_styles'
| 'diet'
| 'political_beliefs'
| 'is_smoker'
| 'has_kids'
| 'pref_gender'
@@ -54,14 +60,20 @@ export const initialFilters: Partial<FilterFields> = {
radius: undefined,
name: undefined,
genders: undefined,
education_levels: undefined,
pref_age_max: undefined,
pref_age_min: undefined,
has_kids: undefined,
wants_kids_strength: undefined,
is_smoker: undefined,
pref_relation_styles: undefined,
pref_romantic_styles: undefined,
diet: undefined,
political_beliefs: undefined,
pref_gender: undefined,
shortBio: undefined,
drinks_min: undefined,
drinks_max: undefined,
orderBy: 'created_time',
}

View File

@@ -11,7 +11,7 @@ export interface HasKidsLabelsMap {
export const hasKidsLabels: HasKidsLabelsMap = {
no_preference: {
name: 'Any kids',
shortName: 'Any kids',
shortName: 'Either',
value: -1,
},
has_kids: {

View File

@@ -3,19 +3,20 @@ import { Row, SupabaseClient } from 'common/supabase/utils'
export type Notification = {
id: string
userId: string
title?: string
reasonText?: string
reason: string
reason?: string
createdTime: number
viewTime?: number
isSeen: boolean
sourceId: string
sourceId?: string
sourceType: string
sourceUpdateType?: 'created' | 'updated' | 'deleted'
sourceUserName: string
sourceUserUsername: string
sourceUserAvatarUrl: string
sourceUserName?: string
sourceUserUsername?: string
sourceUserAvatarUrl?: string
sourceText: string
data?: { [key: string]: any }
@@ -29,12 +30,12 @@ export type Notification = {
isSeenOnHref?: string
}
export const NOTIFICATION_TYPES_TO_SELECT = [
'new_match', // new match markets
'comment_on_profile', // endorsements
'love_like',
'love_ship',
]
// export const NOTIFICATION_TYPES_TO_SELECT = [
// 'new_match', // new match markets
// 'comment_on_profile', // endorsements
// 'profile_like',
// 'profile_ship',
// ]
export const NOTIFICATIONS_PER_PAGE = 30

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