mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-04-04 14:53:33 -04:00
Compare commits
241 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17f9e72a9f | ||
|
|
120aeed56f | ||
|
|
8128c3b2d7 | ||
|
|
4581a33cae | ||
|
|
d43e2af3ae | ||
|
|
0283eb4d85 | ||
|
|
f483ae42a8 | ||
|
|
f974eba465 | ||
|
|
7d7969fe0f | ||
|
|
2a3d7e8362 | ||
|
|
a38c03c4e0 | ||
|
|
342a0c612a | ||
|
|
f1f9970407 | ||
|
|
c83a3e6315 | ||
|
|
fbc65e7e2a | ||
|
|
d9e9407cab | ||
|
|
d0881b76e0 | ||
|
|
61c867b49c | ||
|
|
87de30d257 | ||
|
|
817605417c | ||
|
|
65b018db2a | ||
|
|
addb52e3fa | ||
|
|
c3124ec7c3 | ||
|
|
b1caa6dfdc | ||
|
|
26f28d55d9 | ||
|
|
cb66688529 | ||
|
|
40c61f11be | ||
|
|
9b45c75a5b | ||
|
|
09425c1910 | ||
|
|
591798e98c | ||
|
|
acdd82a680 | ||
|
|
5719ac3209 | ||
|
|
2ac687b0c2 | ||
|
|
a86a249f05 | ||
|
|
e49a7b0bb4 | ||
|
|
e904a7949c | ||
|
|
080d8110df | ||
|
|
d90826e851 | ||
|
|
e495da692b | ||
|
|
52970ef93e | ||
|
|
8f641d117a | ||
|
|
d164ebc7da | ||
|
|
632cc5810d | ||
|
|
e565a6c77f | ||
|
|
c1fe700d7a | ||
|
|
06ee267804 | ||
|
|
aad722c723 | ||
|
|
aefc58b636 | ||
|
|
fdd96507b8 | ||
|
|
2ad87a5ec5 | ||
|
|
b94cdba5af | ||
|
|
725261335c | ||
|
|
5fb0051fc6 | ||
|
|
1247847739 | ||
|
|
18cb4e74d6 | ||
|
|
e07cb7fca9 | ||
|
|
dc54ed46f8 | ||
|
|
0415d86d71 | ||
|
|
b8b95be5ce | ||
|
|
46820f0986 | ||
|
|
dcc022ac7f | ||
|
|
9142f0d633 | ||
|
|
181c72befe | ||
|
|
99f3459978 | ||
|
|
75fbc9679c | ||
|
|
700b7774b1 | ||
|
|
d9f0a9b1ca | ||
|
|
70644ff26d | ||
|
|
bbefcc3bc8 | ||
|
|
09767dbae3 | ||
|
|
57eafa95ba | ||
|
|
f4f28a411e | ||
|
|
f6059ef5c7 | ||
|
|
e3fa4efa95 | ||
|
|
6884a91eb8 | ||
|
|
71ba018a42 | ||
|
|
10f5232ac3 | ||
|
|
78d707484d | ||
|
|
69db66fbbb | ||
|
|
99691cd7ee | ||
|
|
47cef359ca | ||
|
|
046105498f | ||
|
|
4d3ef5dd2a | ||
|
|
8bcd5623bf | ||
|
|
a29b4a3a8e | ||
|
|
dee0fb396b | ||
|
|
b5c707e07f | ||
|
|
8fe35bd1d7 | ||
|
|
6c864c35cd | ||
|
|
f00acf6af1 | ||
|
|
49e1599bc4 | ||
|
|
7311d4b724 | ||
|
|
fa44e348a2 | ||
|
|
8cba02741c | ||
|
|
48d04d5e72 | ||
|
|
7cac25c0e2 | ||
|
|
88b0fa0163 | ||
|
|
3fcef24cc9 | ||
|
|
d9fba6ce6b | ||
|
|
8bc2f0c40e | ||
|
|
21254695d5 | ||
|
|
f063f0a6f4 | ||
|
|
2d847cbcdb | ||
|
|
547e99f526 | ||
|
|
a9794cd2ee | ||
|
|
c651abd8ae | ||
|
|
15781475b6 | ||
|
|
26a28175fd | ||
|
|
aa3680934b | ||
|
|
0b36586ddf | ||
|
|
7b58acac0d | ||
|
|
27bf4eadf9 | ||
|
|
c8d4353888 | ||
|
|
4876ca2643 | ||
|
|
e06a382c94 | ||
|
|
d1a421ca15 | ||
|
|
cd3c8d89d0 | ||
|
|
1f943ccead | ||
|
|
753776fa9a | ||
|
|
9787a2446e | ||
|
|
4cb29d274b | ||
|
|
df55d63f99 | ||
|
|
236e2d48c5 | ||
|
|
30d45d834f | ||
|
|
edf30897f2 | ||
|
|
3d31ebb576 | ||
|
|
d3bac8bcc0 | ||
|
|
a360f80cdf | ||
|
|
0cc7549546 | ||
|
|
283d2743e0 | ||
|
|
b431fa11fa | ||
|
|
648e00867f | ||
|
|
552af7bb6b | ||
|
|
92980f7c79 | ||
|
|
09a563bf73 | ||
|
|
141fa12a20 | ||
|
|
6e0035d4f3 | ||
|
|
97bac4132c | ||
|
|
b23b0280cd | ||
|
|
7ac093a8d0 | ||
|
|
dfc524b957 | ||
|
|
65ba0d348b | ||
|
|
ed07031539 | ||
|
|
93f3690344 | ||
|
|
1341d1356a | ||
|
|
38dcf16c03 | ||
|
|
8696a42959 | ||
|
|
c6fc7db1e9 | ||
|
|
58540aca57 | ||
|
|
b7b75279c2 | ||
|
|
204a35d026 | ||
|
|
fb2841f198 | ||
|
|
5de055c977 | ||
|
|
084659ea3d | ||
|
|
c1a414afab | ||
|
|
a5747034d6 | ||
|
|
fda52fec97 | ||
|
|
e38ec79618 | ||
|
|
1ef125db12 | ||
|
|
b580b640bd | ||
|
|
214bddaca4 | ||
|
|
065d489869 | ||
|
|
46ffefbbb9 | ||
|
|
a19db3bca9 | ||
|
|
2c8d8d9989 | ||
|
|
d52943e31e | ||
|
|
3eababb742 | ||
|
|
8a954d3c20 | ||
|
|
8516901032 | ||
|
|
3f2d246fec | ||
|
|
58fdaa26ca | ||
|
|
7dc1a8790d | ||
|
|
70c9ec1d73 | ||
|
|
2bcbbc96ad | ||
|
|
527d36a159 | ||
|
|
2ce21247ee | ||
|
|
8ea6c406e0 | ||
|
|
e22f50ecd3 | ||
|
|
20dcd98fdf | ||
|
|
bc5708857a | ||
|
|
b9c045ebfb | ||
|
|
c69bd7018e | ||
|
|
078d149175 | ||
|
|
be9f0bd061 | ||
|
|
a4723563f5 | ||
|
|
1fdcd24f28 | ||
|
|
a43480db92 | ||
|
|
e85a072f1c | ||
|
|
bbfa2a4eab | ||
|
|
2f2db4ded8 | ||
|
|
7296a0d2cd | ||
|
|
08e02b6ac0 | ||
|
|
715811d7fd | ||
|
|
c7d6ae6995 | ||
|
|
b1d1396944 | ||
|
|
25a319710e | ||
|
|
796b13dd62 | ||
|
|
8197863ac5 | ||
|
|
89bd164d43 | ||
|
|
80d7061e5f | ||
|
|
c49bac3a09 | ||
|
|
06d53fe801 | ||
|
|
15ba529938 | ||
|
|
83054d0cd1 | ||
|
|
8da486adf2 | ||
|
|
32bc3847fa | ||
|
|
5d763c18c8 | ||
|
|
bd3920cfff | ||
|
|
06d94332b6 | ||
|
|
50614484d8 | ||
|
|
c29d3d8c92 | ||
|
|
26f46af375 | ||
|
|
32b1491dd0 | ||
|
|
51b8a6c80a | ||
|
|
0f63d6d3a0 | ||
|
|
4771b08773 | ||
|
|
9b880101fd | ||
|
|
594806d6e8 | ||
|
|
e9afd4db2f | ||
|
|
b23efe4089 | ||
|
|
e33be41a93 | ||
|
|
33b09df872 | ||
|
|
e9050d0aa0 | ||
|
|
baeb2a33fe | ||
|
|
4ad89acdc7 | ||
|
|
7d87af8f5c | ||
|
|
65c0e84e2a | ||
|
|
7b15d85871 | ||
|
|
ad8ec0f4fd | ||
|
|
2d05d83dd0 | ||
|
|
bd45066b13 | ||
|
|
8ee4274054 | ||
|
|
83a7ed4d6b | ||
|
|
07dbd86ac6 | ||
|
|
0e671d2cc0 | ||
|
|
2d6d3c04ce | ||
|
|
b0148963c7 | ||
|
|
13356950f3 | ||
|
|
629bcb30a7 | ||
|
|
03721fff1c | ||
|
|
2a6911ae3d |
4
.github/FUNDING.yml
vendored
4
.github/FUNDING.yml
vendored
@@ -1,8 +1,8 @@
|
|||||||
# These are supported funding model platforms
|
# 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
|
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
|
ko_fi: compassconnections # Replace with a single Ko-fi username
|
||||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
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
|
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -68,6 +68,7 @@ email-preview
|
|||||||
*.jpeg
|
*.jpeg
|
||||||
*.gif
|
*.gif
|
||||||
*.svg
|
*.svg
|
||||||
|
*.ico
|
||||||
*.mp4
|
*.mp4
|
||||||
*.mov
|
*.mov
|
||||||
*.avi
|
*.avi
|
||||||
|
|||||||
24
README.md
24
README.md
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
[](https://github.com/CompassConnections/Compass/actions/workflows/ci.yml)
|
[](https://github.com/CompassConnections/Compass/actions/workflows/ci.yml)
|
||||||
[](https://github.com/CompassConnections/Compass/actions/workflows/cd.yml)
|
[](https://github.com/CompassConnections/Compass/actions/workflows/cd.yml)
|
||||||

|

|
||||||
|
|
||||||
# Compass
|
# Compass
|
||||||
|
|
||||||
@@ -31,8 +31,8 @@ No contribution is too small—whether it’s 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.
|
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:
|
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
|
- Ask or DM an admin on [Discord](https://discord.gg/8Vd7jzqjun)
|
||||||
- Email hello@compassmeet.com
|
- Email hello@compassmeet.com
|
||||||
- Raise an issue on GitHub
|
- 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] Clean up learn more page
|
||||||
- [x] Add dark theme
|
- [x] Add dark theme
|
||||||
- [ ] Add profile features (intellectual interests, cause areas, personality type, conflict style, etc.)
|
- [ ] Add profile fields (intellectual interests, cause areas, personality type, conflict style, timezone, etc.)
|
||||||
- [ ] Add filters to search through remaining profile features (politics, religion, education level, etc.)
|
- [ ] Add filters to search through remaining profile fields (politics, religion, education level, etc.)
|
||||||
- [ ] Cover with tests (very important, just the test template and framework are ready)
|
- [ ] Cover with tests (crucial, just the test template and framework are ready)
|
||||||
- [ ] Make the app more user-friendly and appealing (UI/UX)
|
- [ ] Make the app more user-friendly and appealing (UI/UX)
|
||||||
- [ ] Clean up terms and conditions (convert to Markdown)
|
- [ ] Clean up terms and conditions (convert to Markdown)
|
||||||
- [ ] Clean up privacy notice (convert to Markdown)
|
- [ ] Clean up privacy notice (convert to Markdown)
|
||||||
@@ -152,7 +152,17 @@ Note: it's normal if page loading locally is much slower than the deployed versi
|
|||||||
|
|
||||||
Now you can start contributing by making changes and submitting pull requests!
|
Now you can start contributing by making changes and submitting pull requests!
|
||||||
|
|
||||||
See [development.md](docs/development.md) for additional instructions, such as adding new profile features.
|
We recommend using a good code editor (VSCode, WebStorm, Cursor, etc.) with Typescript support and a good AI assistant (GitHub Copilot, etc.) to make your life easier. To debug, you can use the browser developer tools (F12), specifically:
|
||||||
|
- Components tab to see the React component tree and props (you need to install the [React Developer Tools](https://react.dev/learn/react-developer-tools) extension)
|
||||||
|
- Console tab for errors and logs
|
||||||
|
- Network tab to see the requests and responses
|
||||||
|
- Storage tab to see cookies and local storage
|
||||||
|
|
||||||
|
You can also add `console.log()` statements in the code.
|
||||||
|
|
||||||
|
If you are new to Typescript or the open-source space, you could start with small changes, such as tweaking some web components or improving wording in some pages. You can find those files in `web/public/md/`.
|
||||||
|
|
||||||
|
See [development.md](docs/development.md) for additional instructions, such as adding new profile fields.
|
||||||
|
|
||||||
### Submission
|
### Submission
|
||||||
|
|
||||||
|
|||||||
@@ -8,5 +8,5 @@
|
|||||||
|
|
||||||
## Reporting a Vulnerability
|
## 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.
|
||||||
|
|
||||||
|
|||||||
@@ -161,5 +161,4 @@ docker rmi -f $(docker images -aq)
|
|||||||
|
|
||||||
### Documentation
|
### Documentation
|
||||||
|
|
||||||
The API docs are available at https://api.compassmeet.com. They are defined in [openapi.json](openapi.json).
|
The API doc is available at https://api.compassmeet.com. It's dynamically prepared in [app.ts](src/app.ts).
|
||||||
Just a few endpoints are mentioned in that JSON doc. Feel free to help by adding the remaining ones!
|
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,21 +4,23 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"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: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": "yarn watch:serve",
|
||||||
"dev": "npx concurrently -n COMPILE,SERVER -c cyan,green \"yarn watch:compile\" \"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": "yarn compile && yarn dist:clean && yarn dist:copy",
|
||||||
"build:fast": "yarn compile && 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)",
|
"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\"",
|
"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": "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: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",
|
"watch": "tsc -w",
|
||||||
"verify": "yarn --cwd=../.. verify",
|
"verify": "yarn --cwd=../.. verify",
|
||||||
"verify:dir": "npx eslint . --max-warnings 0",
|
"verify:dir": "npx eslint . --max-warnings 0",
|
||||||
"regen-types": "cd ../supabase && make ENV=prod regen-types",
|
"regen-types": "cd ../supabase && make ENV=prod regen-types",
|
||||||
"regen-types-dev": "cd ../supabase && make ENV=dev regen-types"
|
"regen-types-dev": "cd ../supabase && make ENV=dev regen-types-dev"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
@@ -44,29 +46,32 @@
|
|||||||
"colors": "1.4.0",
|
"colors": "1.4.0",
|
||||||
"cors": "2.8.5",
|
"cors": "2.8.5",
|
||||||
"dayjs": "1.11.4",
|
"dayjs": "1.11.4",
|
||||||
"express": "4.18.1",
|
"express": "5.0.0",
|
||||||
"firebase-admin": "13.5.0",
|
"firebase-admin": "13.5.0",
|
||||||
"gcp-metadata": "6.1.0",
|
"gcp-metadata": "6.1.0",
|
||||||
"jsonwebtoken": "9.0.0",
|
"jsonwebtoken": "9.0.0",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
|
"openapi-types": "12.1.3",
|
||||||
"pg-promise": "11.4.1",
|
"pg-promise": "11.4.1",
|
||||||
"posthog-node": "4.11.0",
|
"posthog-node": "4.11.0",
|
||||||
|
"react": "18.2.0",
|
||||||
|
"react-dom": "18.2.0",
|
||||||
"resend": "4.1.2",
|
"resend": "4.1.2",
|
||||||
"string-similarity": "4.0.4",
|
"string-similarity": "4.0.4",
|
||||||
"swagger-jsdoc": "6.2.8",
|
"swagger-jsdoc": "6.2.8",
|
||||||
"swagger-ui-express": "5.0.1",
|
"swagger-ui-express": "5.0.1",
|
||||||
"tsconfig-paths": "4.2.0",
|
"tsconfig-paths": "4.2.0",
|
||||||
"twitter-api-v2": "1.15.0",
|
"twitter-api-v2": "1.15.0",
|
||||||
"ws": "8.17.0",
|
"web-push": "3.6.7",
|
||||||
"react": "18.2.0",
|
"ws": "8.17.1",
|
||||||
"react-dom": "18.2.0",
|
"zod": "3.22.3"
|
||||||
"zod": "3.21.4"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/cors": "2.8.17",
|
"@types/cors": "2.8.17",
|
||||||
"@types/react": "18.3.5",
|
"@types/react": "18.3.5",
|
||||||
"@types/react-dom": "18.3.0",
|
"@types/react-dom": "18.3.0",
|
||||||
"@types/swagger-ui-express": "4.1.8",
|
"@types/swagger-ui-express": "4.1.8",
|
||||||
|
"@types/web-push": "3.6.4",
|
||||||
"@types/ws": "8.5.10"
|
"@types/ws": "8.5.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {blockUser, unblockUser} from './block-user'
|
|||||||
import {getCompatibleProfilesHandler} from './compatible-profiles'
|
import {getCompatibleProfilesHandler} from './compatible-profiles'
|
||||||
import {createComment} from './create-comment'
|
import {createComment} from './create-comment'
|
||||||
import {createCompatibilityQuestion} from './create-compatibility-question'
|
import {createCompatibilityQuestion} from './create-compatibility-question'
|
||||||
|
import {setCompatibilityAnswer} from './set-compatibility-answer'
|
||||||
import {createProfile} from './create-profile'
|
import {createProfile} from './create-profile'
|
||||||
import {createUser} from './create-user'
|
import {createUser} from './create-user'
|
||||||
import {getCompatibilityQuestions} from './get-compatibililty-questions'
|
import {getCompatibilityQuestions} from './get-compatibililty-questions'
|
||||||
@@ -19,7 +20,6 @@ import {getLikesAndShips} from './get-likes-and-ships'
|
|||||||
import {getProfileAnswers} from './get-profile-answers'
|
import {getProfileAnswers} from './get-profile-answers'
|
||||||
import {getProfiles} from './get-profiles'
|
import {getProfiles} from './get-profiles'
|
||||||
import {getSupabaseToken} from './get-supabase-token'
|
import {getSupabaseToken} from './get-supabase-token'
|
||||||
import {getDisplayUser, getUser} from './get-user'
|
|
||||||
import {getMe} from './get-me'
|
import {getMe} from './get-me'
|
||||||
import {hasFreeLike} from './has-free-like'
|
import {hasFreeLike} from './has-free-like'
|
||||||
import {health} from './health'
|
import {health} from './health'
|
||||||
@@ -40,7 +40,7 @@ import {getCurrentPrivateUser} from './get-current-private-user'
|
|||||||
import {createPrivateUserMessage} from './create-private-user-message'
|
import {createPrivateUserMessage} from './create-private-user-message'
|
||||||
import {
|
import {
|
||||||
getChannelMemberships,
|
getChannelMemberships,
|
||||||
getChannelMessages,
|
getChannelMessagesEndpoint,
|
||||||
getLastSeenChannelTime,
|
getLastSeenChannelTime,
|
||||||
setChannelLastSeenTime,
|
setChannelLastSeenTime,
|
||||||
} from 'api/get-private-messages'
|
} from 'api/get-private-messages'
|
||||||
@@ -50,10 +50,28 @@ import {leavePrivateUserMessageChannel} from './leave-private-user-message-chann
|
|||||||
import {updatePrivateUserMessageChannel} from './update-private-user-message-channel'
|
import {updatePrivateUserMessageChannel} from './update-private-user-message-channel'
|
||||||
import {getNotifications} from './get-notifications'
|
import {getNotifications} from './get-notifications'
|
||||||
import {updateNotifSettings} from './update-notif-setting'
|
import {updateNotifSettings} from './update-notif-setting'
|
||||||
|
import {setLastOnlineTime} from './set-last-online-time'
|
||||||
import swaggerUi from "swagger-ui-express"
|
import swaggerUi from "swagger-ui-express"
|
||||||
import * as fs from "fs"
|
|
||||||
import {sendSearchNotifications} from "api/send-search-notifications";
|
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({})
|
const allowCorsUnrestricted: RequestHandler = cors({})
|
||||||
|
|
||||||
function cacheController(policy?: string): RequestHandler {
|
function cacheController(policy?: string): RequestHandler {
|
||||||
@@ -101,33 +119,200 @@ const apiErrorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
|
|||||||
export const app = express()
|
export const app = express()
|
||||||
app.use(requestMonitoring)
|
app.use(requestMonitoring)
|
||||||
|
|
||||||
const swaggerDocument = JSON.parse(fs.readFileSync("./openapi.json", "utf-8"))
|
const schemaCache = new WeakMap<ZodTypeAny, any>();
|
||||||
swaggerDocument.info = {
|
|
||||||
...swaggerDocument.info,
|
export function zodToOpenApiSchema(
|
||||||
description: "Compass is a free, open-source platform to help people form deep, meaningful, and lasting connections — whether platonic, romantic, or collaborative. It’s 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.",
|
zodObj: ZodTypeAny,
|
||||||
version: "1.0.0",
|
nameHint?: string
|
||||||
contact: {
|
): any { // Prevent infinite recursion
|
||||||
name: "Compass",
|
if (schemaCache.has(zodObj)) {
|
||||||
email: "hello@compassmeet.com",
|
return schemaCache.get(zodObj);
|
||||||
url: "https://compassmeet.com"
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
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. It’s 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("/")
|
const rootPath = pathWithPrefix("/")
|
||||||
app.get(rootPath, swaggerUi.setup(swaggerDocument))
|
app.get(rootPath, swaggerUi.setup(swaggerDocument))
|
||||||
app.use(rootPath, swaggerUi.serve)
|
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> } = {
|
const handlers: { [k in APIPath]: APIHandler<k> } = {
|
||||||
health: health,
|
health: health,
|
||||||
'get-supabase-token': getSupabaseToken,
|
'get-supabase-token': getSupabaseToken,
|
||||||
'get-notifications': getNotifications,
|
'get-notifications': getNotifications,
|
||||||
'mark-all-notifs-read': markAllNotifsRead,
|
'mark-all-notifs-read': markAllNotifsRead,
|
||||||
'user/:username': getUser,
|
// 'user/:username': getUser,
|
||||||
'user/:username/lite': getDisplayUser,
|
// 'user/:username/lite': getDisplayUser,
|
||||||
'user/by-id/:id': getUser,
|
'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/block': blockUser,
|
||||||
'user/by-id/:id/unblock': unblockUser,
|
'user/by-id/:id/unblock': unblockUser,
|
||||||
'search-users': searchUsers,
|
'search-users': searchUsers,
|
||||||
@@ -153,6 +338,10 @@ const handlers: { [k in APIPath]: APIHandler<k> } = {
|
|||||||
'create-comment': createComment,
|
'create-comment': createComment,
|
||||||
'hide-comment': hideComment,
|
'hide-comment': hideComment,
|
||||||
'create-compatibility-question': createCompatibilityQuestion,
|
'create-compatibility-question': createCompatibilityQuestion,
|
||||||
|
'set-compatibility-answer': setCompatibilityAnswer,
|
||||||
|
'create-vote': createVote,
|
||||||
|
'vote': vote,
|
||||||
|
'contact': contact,
|
||||||
'compatible-profiles': getCompatibleProfilesHandler,
|
'compatible-profiles': getCompatibleProfilesHandler,
|
||||||
'search-location': searchLocation,
|
'search-location': searchLocation,
|
||||||
'search-near-city': searchNearCity,
|
'search-near-city': searchNearCity,
|
||||||
@@ -161,9 +350,14 @@ const handlers: { [k in APIPath]: APIHandler<k> } = {
|
|||||||
'update-private-user-message-channel': updatePrivateUserMessageChannel,
|
'update-private-user-message-channel': updatePrivateUserMessageChannel,
|
||||||
'leave-private-user-message-channel': leavePrivateUserMessageChannel,
|
'leave-private-user-message-channel': leavePrivateUserMessageChannel,
|
||||||
'get-channel-memberships': getChannelMemberships,
|
'get-channel-memberships': getChannelMemberships,
|
||||||
'get-channel-messages': getChannelMessages,
|
'get-channel-messages': getChannelMessagesEndpoint,
|
||||||
'get-channel-seen-time': getLastSeenChannelTime,
|
'get-channel-seen-time': getLastSeenChannelTime,
|
||||||
'set-channel-seen-time': setChannelLastSeenTime,
|
'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]) => {
|
Object.entries(handlers).forEach(([path, handler]) => {
|
||||||
@@ -191,8 +385,6 @@ Object.entries(handlers).forEach(([path, handler]) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// console.debug('COMPASS_API_KEY:', process.env.COMPASS_API_KEY)
|
|
||||||
|
|
||||||
// Internal Endpoints
|
// Internal Endpoints
|
||||||
app.post(pathWithPrefix("/internal/send-search-notifications"),
|
app.post(pathWithPrefix("/internal/send-search-notifications"),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
@@ -206,6 +398,10 @@ app.post(pathWithPrefix("/internal/send-search-notifications"),
|
|||||||
return res.status(200).json(result)
|
return res.status(200).json(result)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to send notifications:", err);
|
console.error("Failed to send notifications:", err);
|
||||||
|
await sendDiscordMessage(
|
||||||
|
"Failed to send [daily notifications](https://console.cloud.google.com/cloudscheduler?project=compass-130ba) for bookmarked searches...",
|
||||||
|
"health"
|
||||||
|
)
|
||||||
return res.status(500).json({error: "Internal server error"});
|
return res.status(500).json({error: "Internal server error"});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { groupBy, sortBy } from 'lodash'
|
import { groupBy, sortBy } from 'lodash'
|
||||||
import { APIError, type APIHandler } from 'api/helpers/endpoint'
|
import { APIError, type APIHandler } from 'api/helpers/endpoint'
|
||||||
import { getCompatibilityScore } from 'common/love/compatibility-score'
|
import { getCompatibilityScore } from 'common/profiles/compatibility-score'
|
||||||
import {
|
import {
|
||||||
getProfile,
|
getProfile,
|
||||||
getCompatibilityAnswers,
|
getCompatibilityAnswers,
|
||||||
getGenderCompatibleProfiles,
|
getGenderCompatibleProfiles,
|
||||||
} from 'shared/love/supabase'
|
} from 'shared/profiles/supabase'
|
||||||
import { log } from 'shared/utils'
|
import { log } from 'shared/utils'
|
||||||
|
|
||||||
export const getCompatibleProfilesHandler: APIHandler<
|
export const getCompatibleProfilesHandler: APIHandler<
|
||||||
|
|||||||
41
backend/api/src/contact.ts
Normal file
41
backend/api/src/contact.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import {APIError, APIHandler} from './helpers/endpoint'
|
||||||
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
import {insert} from 'shared/supabase/utils'
|
||||||
|
import {tryCatch} from 'common/util/try-catch'
|
||||||
|
import {sendDiscordMessage} from "common/discord/core";
|
||||||
|
import {jsonToMarkdown} from "common/md";
|
||||||
|
|
||||||
|
// Stores a contact message into the `contact` table
|
||||||
|
// Web sends TipTap JSON in `content`; we store it as string in `description`.
|
||||||
|
// If optional content metadata is provided, we include it; otherwise we fall back to user-centric defaults.
|
||||||
|
export const contact: APIHandler<'contact'> = async (
|
||||||
|
{content, userId},
|
||||||
|
_auth
|
||||||
|
) => {
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
|
|
||||||
|
const {error} = await tryCatch(
|
||||||
|
insert(pg, 'contact', {
|
||||||
|
user_id: userId,
|
||||||
|
content: JSON.stringify(content),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error) throw new APIError(500, 'Failed to submit contact message')
|
||||||
|
|
||||||
|
const continuation = async () => {
|
||||||
|
try {
|
||||||
|
const md = jsonToMarkdown(content)
|
||||||
|
const message: string = `**New Contact Message**\n${md}`
|
||||||
|
await sendDiscordMessage(message, 'contact')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to send discord contact', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: {},
|
||||||
|
continue: continuation,
|
||||||
|
}
|
||||||
|
}
|
||||||
23
backend/api/src/create-bookmarked-search.ts
Normal file
23
backend/api/src/create-bookmarked-search.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ export const createCompatibilityQuestion: APIHandler<
|
|||||||
const pg = createSupabaseDirectClient()
|
const pg = createSupabaseDirectClient()
|
||||||
|
|
||||||
const { data, error } = await tryCatch(
|
const { data, error } = await tryCatch(
|
||||||
insert(pg, 'love_questions', {
|
insert(pg, 'compatibility_prompts', {
|
||||||
creator_id: creator.id,
|
creator_id: creator.id,
|
||||||
question,
|
question,
|
||||||
answer_type: 'compatibility_multiple_choice',
|
answer_type: 'compatibility_multiple_choice',
|
||||||
|
|||||||
76
backend/api/src/create-notification.ts
Normal file
76
backend/api/src/create-notification.ts
Normal 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)
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@ import { APIError, APIHandler } from 'api/helpers/endpoint'
|
|||||||
import { filterDefined } from 'common/util/array'
|
import { filterDefined } from 'common/util/array'
|
||||||
import { uniq } from 'lodash'
|
import { uniq } from 'lodash'
|
||||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
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'
|
import { getPrivateUser, getUser } from 'shared/utils'
|
||||||
|
|
||||||
export const createPrivateUserMessageChannel: APIHandler<
|
export const createPrivateUserMessageChannel: APIHandler<
|
||||||
|
|||||||
@@ -1,23 +1,25 @@
|
|||||||
import { APIError, APIHandler } from 'api/helpers/endpoint'
|
import {APIError, APIHandler} from 'api/helpers/endpoint'
|
||||||
import { getUser } from 'shared/utils'
|
import {getUser} from 'shared/utils'
|
||||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
import { MAX_COMMENT_JSON_LENGTH } from 'api/create-comment'
|
import {MAX_COMMENT_JSON_LENGTH} from 'api/create-comment'
|
||||||
import { createPrivateUserMessageMain } from 'api/junk-drawer/private-messages'
|
import {createPrivateUserMessageMain} from 'api/helpers/private-messages'
|
||||||
|
|
||||||
export const createPrivateUserMessage: APIHandler<
|
export const createPrivateUserMessage: APIHandler<
|
||||||
'create-private-user-message'
|
'create-private-user-message'
|
||||||
> = async (body, auth) => {
|
> = async (body, auth) => {
|
||||||
const { content, channelId } = body
|
const {content, channelId} = body
|
||||||
if (JSON.stringify(content).length > MAX_COMMENT_JSON_LENGTH) {
|
if (JSON.stringify(content).length > MAX_COMMENT_JSON_LENGTH) {
|
||||||
throw new APIError(
|
throw new APIError(
|
||||||
400,
|
400,
|
||||||
`Message JSON should be less than ${MAX_COMMENT_JSON_LENGTH}`
|
`Message JSON should be less than ${MAX_COMMENT_JSON_LENGTH}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const pg = createSupabaseDirectClient()
|
|
||||||
const creator = await getUser(auth.uid)
|
const creator = await getUser(auth.uid)
|
||||||
if (!creator) throw new APIError(401, 'Your account was not found')
|
if (!creator) throw new APIError(401, 'Your account was not found')
|
||||||
if (creator.isBannedFromPosting) throw new APIError(403, 'You are banned')
|
if (creator.isBannedFromPosting) throw new APIError(403, 'You are banned')
|
||||||
|
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
return await createPrivateUserMessageMain(
|
return await createPrivateUserMessageMain(
|
||||||
creator,
|
creator,
|
||||||
channelId,
|
channelId,
|
||||||
|
|||||||
@@ -2,36 +2,13 @@ import { APIError, APIHandler } from 'api/helpers/endpoint'
|
|||||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||||
import { log, getUser } from 'shared/utils'
|
import { log, getUser } from 'shared/utils'
|
||||||
import { HOUR_MS } from 'common/util/time'
|
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 { track } from 'shared/analytics'
|
||||||
import { updateUser } from 'shared/supabase/users'
|
import { updateUser } from 'shared/supabase/users'
|
||||||
import { tryCatch } from 'common/util/try-catch'
|
import { tryCatch } from 'common/util/try-catch'
|
||||||
import { insert } from 'shared/supabase/utils'
|
import { insert } from 'shared/supabase/utils'
|
||||||
import {sendDiscordMessage} from "common/discord/core";
|
import {sendDiscordMessage} from "common/discord/core";
|
||||||
|
import {jsonToMarkdown} from "common/md";
|
||||||
function extractTextFromBio(bio: any): string {
|
|
||||||
try {
|
|
||||||
const texts: string[] = []
|
|
||||||
const visit = (node: any) => {
|
|
||||||
if (!node) return
|
|
||||||
if (Array.isArray(node)) {
|
|
||||||
for (const item of node) visit(item)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (typeof node === 'object') {
|
|
||||||
for (const [k, v] of Object.entries(node)) {
|
|
||||||
if (k === 'text' && typeof v === 'string') texts.push(v)
|
|
||||||
else visit(v as any)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
visit(bio)
|
|
||||||
// Remove extra whitespace and join
|
|
||||||
return texts.map((t) => t.trim()).filter(Boolean).join(' ')
|
|
||||||
} catch {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const createProfile: APIHandler<'create-profile'> = async (body, auth) => {
|
export const createProfile: APIHandler<'create-profile'> = async (body, auth) => {
|
||||||
const pg = createSupabaseDirectClient()
|
const pg = createSupabaseDirectClient()
|
||||||
@@ -75,8 +52,8 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
|
|||||||
try {
|
try {
|
||||||
let message: string = `[**${user.name}**](https://www.compassmeet.com/${user.username}) just created a profile`
|
let message: string = `[**${user.name}**](https://www.compassmeet.com/${user.username}) just created a profile`
|
||||||
if (body.bio) {
|
if (body.bio) {
|
||||||
const bioText = extractTextFromBio(body.bio)
|
const bioText = jsonToMarkdown(body.bio)
|
||||||
if (bioText) message += `\n > ${bioText}`
|
if (bioText) message += `\n${bioText}`
|
||||||
}
|
}
|
||||||
await sendDiscordMessage(message, 'members')
|
await sendDiscordMessage(message, 'members')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {insert} from 'shared/supabase/utils'
|
|||||||
import {convertPrivateUser, convertUser} from 'common/supabase/users'
|
import {convertPrivateUser, convertUser} from 'common/supabase/users'
|
||||||
import {getBucket} from "shared/firebase-utils";
|
import {getBucket} from "shared/firebase-utils";
|
||||||
import {sendWelcomeEmail} from "email/functions/helpers";
|
import {sendWelcomeEmail} from "email/functions/helpers";
|
||||||
|
import {setLastOnlineTimeUser} from "api/set-last-online-time";
|
||||||
|
|
||||||
export const createUser: APIHandler<'create-user'> = async (
|
export const createUser: APIHandler<'create-user'> = async (
|
||||||
props,
|
props,
|
||||||
@@ -134,6 +135,11 @@ export const createUser: APIHandler<'create-user'> = async (
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to sendWelcomeEmail', e)
|
console.error('Failed to sendWelcomeEmail', e)
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
await setLastOnlineTimeUser(auth.uid)
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to set last online time', e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
27
backend/api/src/create-vote.ts
Normal file
27
backend/api/src/create-vote.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||||
|
import { getUser } from 'shared/utils'
|
||||||
|
import { APIHandler, APIError } from './helpers/endpoint'
|
||||||
|
import { insert } from 'shared/supabase/utils'
|
||||||
|
import { tryCatch } from 'common/util/try-catch'
|
||||||
|
|
||||||
|
export const createVote: APIHandler<
|
||||||
|
'create-vote'
|
||||||
|
> = async ({ title, description, isAnonymous }, auth) => {
|
||||||
|
const creator = await getUser(auth.uid)
|
||||||
|
if (!creator) throw new APIError(401, 'Your account was not found')
|
||||||
|
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
|
|
||||||
|
const { data, error } = await tryCatch(
|
||||||
|
insert(pg, 'votes', {
|
||||||
|
creator_id: creator.id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
is_anonymous: isAnonymous,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
if (error) throw new APIError(401, 'Error creating question')
|
||||||
|
|
||||||
|
return { data }
|
||||||
|
}
|
||||||
23
backend/api/src/delete-bookmarked-search.ts
Normal file
23
backend/api/src/delete-bookmarked-search.ts
Normal 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 {}
|
||||||
|
}
|
||||||
@@ -24,8 +24,11 @@ export const deleteMe: APIHandler<'me/delete'> = async (body, auth) => {
|
|||||||
// Remove user data from Supabase
|
// Remove user data from Supabase
|
||||||
const pg = createSupabaseDirectClient()
|
const pg = createSupabaseDirectClient()
|
||||||
await pg.none('DELETE FROM users WHERE id = $1', [userId])
|
await pg.none('DELETE FROM users WHERE id = $1', [userId])
|
||||||
await pg.none('DELETE FROM private_users WHERE id = $1', [userId])
|
// Should cascade delete in other tables
|
||||||
await pg.none('DELETE FROM profiles WHERE user_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 compatibility_answers WHERE creator_id = $1', [userId])
|
||||||
// May need to also delete from other tables in the future (such as messages, compatibility responses, etc.)
|
// May need to also delete from other tables in the future (such as messages, compatibility responses, etc.)
|
||||||
|
|
||||||
// Delete user files from Firebase Storage
|
// Delete user files from Firebase Storage
|
||||||
|
|||||||
@@ -16,28 +16,28 @@ export const getCompatibilityQuestions: APIHandler<
|
|||||||
> = async (_props, _auth) => {
|
> = async (_props, _auth) => {
|
||||||
const pg = createSupabaseDirectClient()
|
const pg = createSupabaseDirectClient()
|
||||||
|
|
||||||
const dbQuestions = await pg.manyOrNone<
|
const questions = await pg.manyOrNone<
|
||||||
Row<'love_questions'> & { answer_count: number; score: number }
|
Row<'compatibility_prompts'> & { answer_count: number; score: number }
|
||||||
>(
|
>(
|
||||||
`SELECT
|
`SELECT
|
||||||
love_questions.*,
|
compatibility_prompts.*,
|
||||||
COUNT(love_compatibility_answers.question_id) as answer_count,
|
COUNT(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
|
AVG(POWER(compatibility_answers.importance + 1 + CASE WHEN compatibility_answers.explanation IS NULL THEN 1 ELSE 0 END, 2)) as score
|
||||||
FROM
|
FROM
|
||||||
love_questions
|
compatibility_prompts
|
||||||
LEFT JOIN
|
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
|
WHERE
|
||||||
love_questions.answer_type = 'compatibility_multiple_choice'
|
compatibility_prompts.answer_type = 'compatibility_multiple_choice'
|
||||||
GROUP BY
|
GROUP BY
|
||||||
love_questions.id
|
compatibility_prompts.id
|
||||||
ORDER BY
|
ORDER BY
|
||||||
score DESC
|
compatibility_prompts.importance_score
|
||||||
`,
|
`,
|
||||||
[]
|
[]
|
||||||
)
|
)
|
||||||
|
|
||||||
const questions = shuffle(dbQuestions)
|
// const questions = shuffle(dbQuestions)
|
||||||
|
|
||||||
// console.debug(
|
// console.debug(
|
||||||
// 'got questions',
|
// 'got questions',
|
||||||
|
|||||||
@@ -20,10 +20,10 @@ export const getLikesAndShipsMain = async (userId: string) => {
|
|||||||
created_time: number
|
created_time: number
|
||||||
}>(
|
}>(
|
||||||
`
|
`
|
||||||
select target_id, love_likes.created_time
|
select target_id, profile_likes.created_time
|
||||||
from love_likes
|
from profile_likes
|
||||||
join profiles on profiles.user_id = love_likes.target_id
|
join profiles on profiles.user_id = profile_likes.target_id
|
||||||
join users on users.id = love_likes.target_id
|
join users on users.id = profile_likes.target_id
|
||||||
where creator_id = $1
|
where creator_id = $1
|
||||||
and looking_for_matches
|
and looking_for_matches
|
||||||
and profiles.pinned_url is not null
|
and profiles.pinned_url is not null
|
||||||
@@ -42,10 +42,10 @@ export const getLikesAndShipsMain = async (userId: string) => {
|
|||||||
created_time: number
|
created_time: number
|
||||||
}>(
|
}>(
|
||||||
`
|
`
|
||||||
select creator_id, love_likes.created_time
|
select creator_id, profile_likes.created_time
|
||||||
from love_likes
|
from profile_likes
|
||||||
join profiles on profiles.user_id = love_likes.creator_id
|
join profiles on profiles.user_id = profile_likes.creator_id
|
||||||
join users on users.id = love_likes.creator_id
|
join users on users.id = profile_likes.creator_id
|
||||||
where target_id = $1
|
where target_id = $1
|
||||||
and looking_for_matches
|
and looking_for_matches
|
||||||
and profiles.pinned_url is not null
|
and profiles.pinned_url is not null
|
||||||
@@ -68,11 +68,11 @@ export const getLikesAndShipsMain = async (userId: string) => {
|
|||||||
}>(
|
}>(
|
||||||
`
|
`
|
||||||
select
|
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
|
target1_id as target_id
|
||||||
from love_ships
|
from profile_ships
|
||||||
join profiles on profiles.user_id = love_ships.target1_id
|
join profiles on profiles.user_id = profile_ships.target1_id
|
||||||
join users on users.id = love_ships.target1_id
|
join users on users.id = profile_ships.target1_id
|
||||||
where target2_id = $1
|
where target2_id = $1
|
||||||
and profiles.looking_for_matches
|
and profiles.looking_for_matches
|
||||||
and profiles.pinned_url is not null
|
and profiles.pinned_url is not null
|
||||||
@@ -81,11 +81,11 @@ export const getLikesAndShipsMain = async (userId: string) => {
|
|||||||
union all
|
union all
|
||||||
|
|
||||||
select
|
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
|
target2_id as target_id
|
||||||
from love_ships
|
from profile_ships
|
||||||
join profiles on profiles.user_id = love_ships.target2_id
|
join profiles on profiles.user_id = profile_ships.target2_id
|
||||||
join users on users.id = love_ships.target2_id
|
join users on users.id = profile_ships.target2_id
|
||||||
where target1_id = $1
|
where target1_id = $1
|
||||||
and profiles.looking_for_matches
|
and profiles.looking_for_matches
|
||||||
and profiles.pinned_url is not null
|
and profiles.pinned_url is not null
|
||||||
|
|||||||
18
backend/api/src/get-messages-count.ts
Normal file
18
backend/api/src/get-messages-count.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import {APIHandler} from './helpers/endpoint'
|
||||||
|
import {createSupabaseDirectClient} from "shared/supabase/init";
|
||||||
|
|
||||||
|
export const getMessagesCount: APIHandler<'get-messages-count'> = async (_, auth) => {
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
|
const result = await pg.one(
|
||||||
|
`
|
||||||
|
SELECT COUNT(*) AS count
|
||||||
|
FROM private_user_messages;
|
||||||
|
`,
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const count = Number(result.count);
|
||||||
|
console.debug('private_user_messages count:', count);
|
||||||
|
return {
|
||||||
|
count: count,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,15 @@
|
|||||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
import { APIHandler } from './helpers/endpoint'
|
import {APIError, APIHandler} from './helpers/endpoint'
|
||||||
import {
|
import {PrivateMessageChannel,} from 'common/supabase/private-messages'
|
||||||
convertPrivateChatMessage,
|
import {groupBy, mapValues} from 'lodash'
|
||||||
PrivateMessageChannel,
|
import {convertPrivateChatMessage} from "shared/supabase/messages";
|
||||||
} from 'common/supabase/private-messages'
|
import {tryCatch} from "common/util/try-catch";
|
||||||
import { groupBy, mapValues } from 'lodash'
|
|
||||||
|
|
||||||
export const getChannelMemberships: APIHandler<
|
export const getChannelMemberships: APIHandler<
|
||||||
'get-channel-memberships'
|
'get-channel-memberships'
|
||||||
> = async (props, auth) => {
|
> = async (props, auth) => {
|
||||||
const pg = createSupabaseDirectClient()
|
const pg = createSupabaseDirectClient()
|
||||||
const { channelId, lastUpdatedTime, createdTime, limit } = props
|
const {channelId, lastUpdatedTime, createdTime, limit} = props
|
||||||
|
|
||||||
let channels: PrivateMessageChannel[]
|
let channels: PrivateMessageChannel[]
|
||||||
const convertRow = (r: any) => ({
|
const convertRow = (r: any) => ({
|
||||||
@@ -24,55 +23,56 @@ export const getChannelMemberships: APIHandler<
|
|||||||
channels = await pg.map(
|
channels = await pg.map(
|
||||||
`select channel_id, notify_after_time, pumcm.created_time, last_updated_time
|
`select channel_id, notify_after_time, pumcm.created_time, last_updated_time
|
||||||
from private_user_message_channel_members pumcm
|
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
|
where user_id = $1
|
||||||
and channel_id = $2
|
and channel_id = $2
|
||||||
limit $3
|
limit $3
|
||||||
`,
|
`,
|
||||||
[auth.uid, channelId, limit],
|
[auth.uid, channelId, limit],
|
||||||
convertRow
|
convertRow
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
channels = await pg.map(
|
channels = await pg.map(
|
||||||
`with latest_channels as (
|
`with latest_channels as (select distinct on (pumc.id) pumc.id as channel_id,
|
||||||
select distinct on (pumc.id) pumc.id as channel_id, notify_after_time, pumc.created_time,
|
notify_after_time,
|
||||||
(select created_time
|
pumc.created_time,
|
||||||
from private_user_messages
|
(select created_time
|
||||||
where channel_id = pumc.id
|
from private_user_messages
|
||||||
and visibility != 'system_status'
|
where channel_id = pumc.id
|
||||||
and user_id != $1
|
and visibility != 'system_status'
|
||||||
order by created_time desc
|
and user_id != $1
|
||||||
limit 1) as last_updated_time, -- last_updated_time is the last possible unseen message time
|
order by created_time desc
|
||||||
pumc.last_updated_time as last_updated_channel_time -- last_updated_channel_time is the last time the channel was updated
|
limit 1) as last_updated_time, -- last_updated_time is the last possible unseen message time
|
||||||
from private_user_message_channels pumc
|
pumc.last_updated_time as last_updated_channel_time -- last_updated_channel_time is the last time the channel was updated
|
||||||
join private_user_message_channel_members pumcm on pumcm.channel_id = pumc.id
|
from private_user_message_channels pumc
|
||||||
inner join private_user_messages pum on pumc.id = pum.channel_id
|
join private_user_message_channel_members pumcm on pumcm.channel_id = pumc.id
|
||||||
and (pum.visibility != 'introduction' or pum.user_id != $1)
|
inner join private_user_messages pum on pumc.id = pum.channel_id
|
||||||
where pumcm.user_id = $1
|
and (pum.visibility != 'introduction' or pum.user_id != $1)
|
||||||
and not status = 'left'
|
where pumcm.user_id = $1
|
||||||
and ($2 is null or pumcm.created_time > $2)
|
and not status = 'left'
|
||||||
and ($4 is null or pumc.last_updated_time > $4)
|
and ($2 is null or pumcm.created_time > $2)
|
||||||
order by pumc.id, pumc.last_updated_time desc
|
and ($4 is null or pumc.last_updated_time > $4)
|
||||||
)
|
order by pumc.id, pumc.last_updated_time desc)
|
||||||
select * from latest_channels
|
select *
|
||||||
|
from latest_channels
|
||||||
order by last_updated_channel_time desc
|
order by last_updated_channel_time desc
|
||||||
limit $3
|
limit $3
|
||||||
`,
|
`,
|
||||||
[auth.uid, createdTime ?? null, limit, lastUpdatedTime ?? null],
|
[auth.uid, createdTime ?? null, limit, lastUpdatedTime ?? null],
|
||||||
convertRow
|
convertRow
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (!channels || channels.length === 0)
|
if (!channels || channels.length === 0)
|
||||||
return { channels: [], memberIdsByChannelId: {} }
|
return {channels: [], memberIdsByChannelId: {}}
|
||||||
const channelIds = channels.map((c) => c.channel_id)
|
const channelIds = channels.map((c) => c.channel_id)
|
||||||
|
|
||||||
const members = await pg.map(
|
const members = await pg.map(
|
||||||
`select channel_id, user_id
|
`select channel_id, user_id
|
||||||
from private_user_message_channel_members
|
from private_user_message_channel_members
|
||||||
where not user_id = $1
|
where not user_id = $1
|
||||||
and channel_id in ($2:list)
|
and channel_id in ($2:list)
|
||||||
and not status = 'left'
|
and not status = 'left'
|
||||||
`,
|
`,
|
||||||
[auth.uid, channelIds],
|
[auth.uid, channelIds],
|
||||||
(r) => ({
|
(r) => ({
|
||||||
channel_id: r.channel_id as number,
|
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,
|
props,
|
||||||
auth
|
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 pg = createSupabaseDirectClient()
|
||||||
const { channelId, limit, id } = props
|
const {data, error} = await tryCatch(pg.map(
|
||||||
return await pg.map(
|
|
||||||
`select *, created_time as created_time_ts
|
`select *, created_time as created_time_ts
|
||||||
from private_user_messages
|
from private_user_messages
|
||||||
where channel_id = $1
|
where channel_id = $1
|
||||||
and exists (select 1 from private_user_message_channel_members pumcm
|
and exists (select 1
|
||||||
where pumcm.user_id = $2
|
from private_user_message_channel_members pumcm
|
||||||
and pumcm.channel_id = $1
|
where pumcm.user_id = $2
|
||||||
)
|
and pumcm.channel_id = $1)
|
||||||
and ($4 is null or id > $4)
|
and ($4 is null or id > $4)
|
||||||
and not visibility = 'system_status'
|
and not visibility = 'system_status'
|
||||||
order by created_time desc
|
order by created_time desc
|
||||||
limit $3
|
limit $3
|
||||||
`,
|
`,
|
||||||
[channelId, auth.uid, limit, id],
|
[channelId, userId, limit, id],
|
||||||
convertPrivateChatMessage
|
convertPrivateChatMessage
|
||||||
)
|
))
|
||||||
|
if (error) {
|
||||||
|
console.error(error)
|
||||||
|
throw new APIError(401, 'Error getting messages')
|
||||||
|
}
|
||||||
|
// console.log('final messages', data)
|
||||||
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getLastSeenChannelTime: APIHandler<
|
export const getLastSeenChannelTime: APIHandler<
|
||||||
'get-channel-seen-time'
|
'get-channel-seen-time'
|
||||||
> = async (props, auth) => {
|
> = async (props, auth) => {
|
||||||
const pg = createSupabaseDirectClient()
|
const pg = createSupabaseDirectClient()
|
||||||
const { channelIds } = props
|
const {channelIds} = props
|
||||||
const unseens = await pg.map(
|
const unseens = await pg.map(
|
||||||
`select distinct on (channel_id) channel_id, created_time
|
`select distinct on (channel_id) channel_id, created_time
|
||||||
from private_user_seen_message_channels
|
from private_user_seen_message_channels
|
||||||
where channel_id = any($1)
|
where channel_id = any ($1)
|
||||||
and user_id = $2
|
and user_id = $2
|
||||||
order by channel_id, created_time desc
|
order by channel_id, created_time desc
|
||||||
`,
|
`,
|
||||||
@@ -137,11 +154,11 @@ export const setChannelLastSeenTime: APIHandler<
|
|||||||
'set-channel-seen-time'
|
'set-channel-seen-time'
|
||||||
> = async (props, auth) => {
|
> = async (props, auth) => {
|
||||||
const pg = createSupabaseDirectClient()
|
const pg = createSupabaseDirectClient()
|
||||||
const { channelId } = props
|
const {channelId} = props
|
||||||
await pg.none(
|
await pg.none(
|
||||||
`insert into private_user_seen_message_channels (user_id, channel_id)
|
`insert into private_user_seen_message_channels (user_id, channel_id)
|
||||||
values ($1, $2)
|
values ($1, $2)
|
||||||
`,
|
`,
|
||||||
[auth.uid, channelId]
|
[auth.uid, channelId]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ export const getProfileAnswers: APIHandler<'get-profile-answers'> = async (
|
|||||||
const { userId } = props
|
const { userId } = props
|
||||||
const pg = createSupabaseDirectClient()
|
const pg = createSupabaseDirectClient()
|
||||||
|
|
||||||
const answers = await pg.manyOrNone<Row<'love_compatibility_answers'>>(
|
const answers = await pg.manyOrNone<Row<'compatibility_answers'>>(
|
||||||
`select * from love_compatibility_answers
|
`select * from compatibility_answers
|
||||||
where
|
where
|
||||||
creator_id = $1
|
creator_id = $1
|
||||||
order by created_time desc
|
order by created_time desc
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {type APIHandler} from 'api/helpers/endpoint'
|
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 {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
import {from, join, limit, orderBy, renderSql, select, where,} from 'shared/supabase/sql-builder'
|
import {from, join, leftJoin, limit, orderBy, renderSql, select, where,} from 'shared/supabase/sql-builder'
|
||||||
import {getCompatibleProfiles} from 'api/compatible-profiles'
|
import {getCompatibleProfiles} from 'api/compatible-profiles'
|
||||||
import {intersection} from 'lodash'
|
import {intersection} from 'lodash'
|
||||||
import {MAX_INT, MIN_BIO_LENGTH, MIN_INT} from "common/constants";
|
import {MAX_INT, MIN_BIO_LENGTH, MIN_INT} from "common/constants";
|
||||||
@@ -12,21 +12,32 @@ export type profileQueryType = {
|
|||||||
// Search and filter parameters
|
// Search and filter parameters
|
||||||
name?: string | undefined,
|
name?: string | undefined,
|
||||||
genders?: String[] | undefined,
|
genders?: String[] | undefined,
|
||||||
|
education_levels?: String[] | undefined,
|
||||||
pref_gender?: String[] | undefined,
|
pref_gender?: String[] | undefined,
|
||||||
pref_age_min?: number | undefined,
|
pref_age_min?: number | undefined,
|
||||||
pref_age_max?: number | undefined,
|
pref_age_max?: number | undefined,
|
||||||
|
drinks_min?: number | undefined,
|
||||||
|
drinks_max?: number | undefined,
|
||||||
pref_relation_styles?: String[] | undefined,
|
pref_relation_styles?: String[] | undefined,
|
||||||
|
pref_romantic_styles?: String[] | undefined,
|
||||||
|
diet?: String[] | undefined,
|
||||||
|
political_beliefs?: String[] | undefined,
|
||||||
wants_kids_strength?: number | undefined,
|
wants_kids_strength?: number | undefined,
|
||||||
has_kids?: number | undefined,
|
has_kids?: number | undefined,
|
||||||
is_smoker?: boolean | undefined,
|
is_smoker?: boolean | undefined,
|
||||||
shortBio?: boolean | undefined,
|
shortBio?: boolean | undefined,
|
||||||
geodbCityIds?: String[] | undefined,
|
geodbCityIds?: String[] | undefined,
|
||||||
|
lat?: number | undefined,
|
||||||
|
lon?: number | undefined,
|
||||||
|
radius?: number | undefined,
|
||||||
compatibleWithUserId?: string | undefined,
|
compatibleWithUserId?: string | undefined,
|
||||||
skipId?: string | undefined,
|
skipId?: string | undefined,
|
||||||
orderBy?: string | undefined,
|
orderBy?: string | undefined,
|
||||||
lastModificationWithin?: string | undefined,
|
lastModificationWithin?: string | undefined,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const userActivityColumns = ['last_online_time']
|
||||||
|
|
||||||
|
|
||||||
export const loadProfiles = async (props: profileQueryType) => {
|
export const loadProfiles = async (props: profileQueryType) => {
|
||||||
const pg = createSupabaseDirectClient()
|
const pg = createSupabaseDirectClient()
|
||||||
@@ -36,21 +47,32 @@ export const loadProfiles = async (props: profileQueryType) => {
|
|||||||
after,
|
after,
|
||||||
name,
|
name,
|
||||||
genders,
|
genders,
|
||||||
|
education_levels,
|
||||||
pref_gender,
|
pref_gender,
|
||||||
pref_age_min,
|
pref_age_min,
|
||||||
pref_age_max,
|
pref_age_max,
|
||||||
|
drinks_min,
|
||||||
|
drinks_max,
|
||||||
pref_relation_styles,
|
pref_relation_styles,
|
||||||
|
pref_romantic_styles,
|
||||||
|
diet,
|
||||||
|
political_beliefs,
|
||||||
wants_kids_strength,
|
wants_kids_strength,
|
||||||
has_kids,
|
has_kids,
|
||||||
is_smoker,
|
is_smoker,
|
||||||
shortBio,
|
shortBio,
|
||||||
geodbCityIds,
|
geodbCityIds,
|
||||||
|
lat,
|
||||||
|
lon,
|
||||||
|
radius,
|
||||||
compatibleWithUserId,
|
compatibleWithUserId,
|
||||||
orderBy: orderByParam = 'created_time',
|
orderBy: orderByParam = 'created_time',
|
||||||
lastModificationWithin,
|
lastModificationWithin,
|
||||||
skipId,
|
skipId,
|
||||||
} = props
|
} = props
|
||||||
|
|
||||||
|
const filterLocation = lat && lon && radius
|
||||||
|
|
||||||
const keywords = name ? name.split(",").map(q => q.trim()).filter(Boolean) : []
|
const keywords = name ? name.split(",").map(q => q.trim()).filter(Boolean) : []
|
||||||
// console.debug('keywords:', keywords)
|
// console.debug('keywords:', keywords)
|
||||||
|
|
||||||
@@ -66,13 +88,24 @@ export const loadProfiles = async (props: profileQueryType) => {
|
|||||||
(l) =>
|
(l) =>
|
||||||
(!name || l.user.name.toLowerCase().includes(name.toLowerCase())) &&
|
(!name || l.user.name.toLowerCase().includes(name.toLowerCase())) &&
|
||||||
(!genders || genders.includes(l.gender)) &&
|
(!genders || genders.includes(l.gender)) &&
|
||||||
|
(!education_levels || education_levels.includes(l.education_level ?? '')) &&
|
||||||
(!pref_gender || intersection(pref_gender, l.pref_gender).length) &&
|
(!pref_gender || intersection(pref_gender, l.pref_gender).length) &&
|
||||||
(!pref_age_min || (l.age ?? MAX_INT) >= pref_age_min) &&
|
(!pref_age_min || (l.age ?? MAX_INT) >= pref_age_min) &&
|
||||||
(!pref_age_max || (l.age ?? MIN_INT) <= pref_age_max) &&
|
(!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 ||
|
(!pref_relation_styles ||
|
||||||
intersection(pref_relation_styles, l.pref_relation_styles).length) &&
|
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 ||
|
||||||
wants_kids_strength == -1 ||
|
wants_kids_strength == -1 ||
|
||||||
|
!l.wants_kids_strength ||
|
||||||
|
l.wants_kids_strength == -1 ||
|
||||||
(wants_kids_strength >= 2
|
(wants_kids_strength >= 2
|
||||||
? l.wants_kids_strength >= wants_kids_strength
|
? l.wants_kids_strength >= wants_kids_strength
|
||||||
: l.wants_kids_strength <= wants_kids_strength)) &&
|
: l.wants_kids_strength <= wants_kids_strength)) &&
|
||||||
@@ -80,11 +113,17 @@ export const loadProfiles = async (props: profileQueryType) => {
|
|||||||
has_kids == -1 ||
|
has_kids == -1 ||
|
||||||
(has_kids == 0 && !l.has_kids) ||
|
(has_kids == 0 && !l.has_kids) ||
|
||||||
(l.has_kids && l.has_kids > 0)) &&
|
(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) &&
|
(l.id.toString() != skipId) &&
|
||||||
(!geodbCityIds ||
|
(!geodbCityIds ||
|
||||||
(l.geodb_city_id && geodbCityIds.includes(l.geodb_city_id))) &&
|
(l.geodb_city_id && geodbCityIds.includes(l.geodb_city_id))) &&
|
||||||
((l.bio_length ?? 0) >= MIN_BIO_LENGTH)
|
(!filterLocation ||(
|
||||||
|
l.city_latitude && l.city_longitude &&
|
||||||
|
Math.abs(l.city_latitude - lat) < radius / 69.0 &&
|
||||||
|
Math.abs(l.city_longitude - lon) < radius / (69.0 * Math.cos(lat * Math.PI / 180)) &&
|
||||||
|
Math.pow(l.city_latitude - lat, 2) + Math.pow((l.city_longitude - lon) * Math.cos(lat * Math.PI / 180), 2) < Math.pow(radius / 69.0, 2)
|
||||||
|
)) &&
|
||||||
|
(shortBio || (l.bio_length ?? 0) >= MIN_BIO_LENGTH)
|
||||||
)
|
)
|
||||||
|
|
||||||
const cursor = after
|
const cursor = after
|
||||||
@@ -97,10 +136,14 @@ export const loadProfiles = async (props: profileQueryType) => {
|
|||||||
return profiles
|
return profiles
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tablePrefix = userActivityColumns.includes(orderByParam) ? 'user_activity' : 'profiles'
|
||||||
|
const userActivityJoin = 'user_activity on user_activity.user_id = profiles.user_id'
|
||||||
|
|
||||||
const query = renderSql(
|
const query = renderSql(
|
||||||
select('profiles.*, name, username, users.data as user'),
|
select('profiles.*, name, username, users.data as user, user_activity.last_online_time'),
|
||||||
from('profiles'),
|
from('profiles'),
|
||||||
join('users on users.id = profiles.user_id'),
|
join('users on users.id = profiles.user_id'),
|
||||||
|
leftJoin(userActivityJoin),
|
||||||
where('looking_for_matches = true'),
|
where('looking_for_matches = true'),
|
||||||
// where(`pinned_url is not null and pinned_url != ''`),
|
// where(`pinned_url is not null and pinned_url != ''`),
|
||||||
where(
|
where(
|
||||||
@@ -113,10 +156,12 @@ export const loadProfiles = async (props: profileQueryType) => {
|
|||||||
{word}
|
{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 &&
|
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 &&
|
pref_age_min &&
|
||||||
where(`age >= $(pref_age_min) or age is null`, {pref_age_min}),
|
where(`age >= $(pref_age_min) or age is null`, {pref_age_min}),
|
||||||
@@ -124,35 +169,79 @@ export const loadProfiles = async (props: profileQueryType) => {
|
|||||||
pref_age_max &&
|
pref_age_max &&
|
||||||
where(`age <= $(pref_age_max) or age is null`, {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 &&
|
pref_relation_styles?.length &&
|
||||||
where(
|
where(
|
||||||
`pref_relation_styles IS NULL OR pref_relation_styles = '{}' OR pref_relation_styles && $(pref_relation_styles)`,
|
`pref_relation_styles IS NULL OR pref_relation_styles = '{}' OR pref_relation_styles && $(pref_relation_styles)`,
|
||||||
{ pref_relation_styles }
|
{pref_relation_styles}
|
||||||
|
),
|
||||||
|
|
||||||
|
pref_romantic_styles?.length &&
|
||||||
|
where(
|
||||||
|
`pref_romantic_styles IS NULL OR pref_romantic_styles = '{}' OR pref_romantic_styles && $(pref_romantic_styles)`,
|
||||||
|
{pref_romantic_styles}
|
||||||
|
),
|
||||||
|
|
||||||
|
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 &&
|
||||||
wants_kids_strength !== -1 &&
|
wants_kids_strength !== -1 &&
|
||||||
where(
|
where(
|
||||||
wants_kids_strength >= 2
|
'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 >= $(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 === 0 && where(`has_kids IS NULL OR has_kids = 0`),
|
||||||
has_kids && has_kids > 0 && where(`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 &&
|
geodbCityIds?.length &&
|
||||||
where(`geodb_city_id = ANY($(geodbCityIds))`, {geodbCityIds}),
|
where(`geodb_city_id = ANY($(geodbCityIds))`, {geodbCityIds}),
|
||||||
|
|
||||||
skipId && where(`user_id != $(skipId)`, {skipId}),
|
// miles par degree of lat: earth's radius (3950 miles) * pi / 180 = 69.0
|
||||||
|
filterLocation && where(`
|
||||||
|
city_latitude BETWEEN $(target_lat) - ($(radius) / 69.0)
|
||||||
|
AND $(target_lat) + ($(radius) / 69.0)
|
||||||
|
AND city_longitude BETWEEN $(target_lon) - ($(radius) / (69.0 * COS(RADIANS($(target_lat)))))
|
||||||
|
AND $(target_lon) + ($(radius) / (69.0 * COS(RADIANS($(target_lat)))))
|
||||||
|
AND SQRT(
|
||||||
|
POWER(city_latitude - $(target_lat), 2)
|
||||||
|
+ POWER((city_longitude - $(target_lon)) * COS(RADIANS($(target_lat))), 2)
|
||||||
|
) <= $(radius) / 69.0
|
||||||
|
`, {target_lat: lat, target_lon: lon, radius}),
|
||||||
|
|
||||||
orderBy(`${orderByParam} desc`),
|
skipId && where(`profiles.user_id != $(skipId)`, {skipId}),
|
||||||
|
|
||||||
|
orderBy(`${tablePrefix}.${orderByParam} DESC`),
|
||||||
after &&
|
after &&
|
||||||
where(
|
where(
|
||||||
`profiles.${orderByParam} < (select profiles.${orderByParam} from profiles where id = $(after))`,
|
`${tablePrefix}.${orderByParam} < (
|
||||||
|
SELECT ${tablePrefix}.${orderByParam}
|
||||||
|
FROM profiles
|
||||||
|
LEFT JOIN ${userActivityJoin}
|
||||||
|
WHERE profiles.id = $(after)
|
||||||
|
)`,
|
||||||
{after}
|
{after}
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|||||||
@@ -17,17 +17,18 @@ export const getUser = async (props: { id: string } | { username: string }) => {
|
|||||||
return toUserAPIResponse(user)
|
return toUserAPIResponse(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getDisplayUser = async (
|
// export const getDisplayUser = async (
|
||||||
props: { id: string } | { username: string }
|
// props: { id: string } | { username: string }
|
||||||
) => {
|
// ) => {
|
||||||
const pg = createSupabaseDirectClient()
|
// console.log('getDisplayUser', props)
|
||||||
const liteUser = await pg.oneOrNone(
|
// const pg = createSupabaseDirectClient()
|
||||||
`select ${displayUserColumns}
|
// const liteUser = await pg.oneOrNone(
|
||||||
from users
|
// `select ${displayUserColumns}
|
||||||
where ${'id' in props ? 'id' : 'username'} = $1`,
|
// from users
|
||||||
['id' in props ? props.id : props.username]
|
// where ${'id' in props ? 'id' : 'username'} = $1`,
|
||||||
)
|
// ['id' in props ? props.id : props.username]
|
||||||
if (!liteUser) throw new APIError(404, 'User not found')
|
// )
|
||||||
|
// if (!liteUser) throw new APIError(404, 'User not found')
|
||||||
return removeNullOrUndefinedProps(liteUser)
|
//
|
||||||
}
|
// return removeNullOrUndefinedProps(liteUser)
|
||||||
|
// }
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export const getHasFreeLike = async (userId: string) => {
|
|||||||
const likeGivenToday = await pg.oneOrNone<object>(
|
const likeGivenToday = await pg.oneOrNone<object>(
|
||||||
`
|
`
|
||||||
select 1
|
select 1
|
||||||
from love_likes
|
from profile_likes
|
||||||
where creator_id = $1
|
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
|
||||||
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')
|
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')
|
||||||
|
|||||||
@@ -174,11 +174,63 @@ export type APIHandler<N extends APIPath> = (
|
|||||||
req: Request
|
req: Request
|
||||||
) => Promise<APIResponseOptionalContinue<N>>
|
) => Promise<APIResponseOptionalContinue<N>>
|
||||||
|
|
||||||
|
// Simple in-memory fixed-window rate limiter keyed by auth uid (or IP if unauthenticated)
|
||||||
|
// Not suitable for multi-instance deployments without a shared store, but provides basic protection.
|
||||||
|
// Limits are configurable via env:
|
||||||
|
// API_RATE_LIMIT_PER_MIN_AUTHED
|
||||||
|
// API_RATE_LIMIT_PER_MIN_UNAUTHED
|
||||||
|
// Endpoints can be exempted by adding their name to RATE_LIMIT_EXEMPT (comma-separated)
|
||||||
|
const __rateLimitState: Map<string, { windowStart: number; count: number }> = new Map()
|
||||||
|
|
||||||
|
function getRateLimitConfig() {
|
||||||
|
const authed = Number(process.env.API_RATE_LIMIT_PER_MIN_AUTHED ?? 120)
|
||||||
|
const unAuthed = Number(process.env.API_RATE_LIMIT_PER_MIN_UNAUTHED ?? 120)
|
||||||
|
return {authedLimit: authed, unAuthLimit: unAuthed}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rateLimitKey(name: string, req: Request, auth?: AuthedUser) {
|
||||||
|
if (auth) return `uid:${auth.uid}`
|
||||||
|
// fallback to IP for unauthenticated requests
|
||||||
|
return `ip:${req.ip}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkRateLimit(name: string, req: Request, res: Response, auth?: AuthedUser) {
|
||||||
|
const {authedLimit, unAuthLimit} = getRateLimitConfig()
|
||||||
|
|
||||||
|
const key = rateLimitKey(name, req, auth)
|
||||||
|
const limit = auth ? authedLimit : unAuthLimit
|
||||||
|
const now = Date.now()
|
||||||
|
const windowMs = 60_000
|
||||||
|
const windowStart = Math.floor(now / windowMs) * windowMs
|
||||||
|
|
||||||
|
let state = __rateLimitState.get(key)
|
||||||
|
if (!state || state.windowStart !== windowStart) {
|
||||||
|
state = {windowStart, count: 0}
|
||||||
|
__rateLimitState.set(key, state)
|
||||||
|
}
|
||||||
|
state.count += 1
|
||||||
|
|
||||||
|
const remaining = Math.max(0, limit - state.count)
|
||||||
|
const reset = Math.ceil((state.windowStart + windowMs - now) / 1000)
|
||||||
|
|
||||||
|
// Set standard-ish rate limit headers
|
||||||
|
res.setHeader('X-RateLimit-Limit', String(limit))
|
||||||
|
res.setHeader('X-RateLimit-Remaining', String(Math.max(0, remaining)))
|
||||||
|
res.setHeader('X-RateLimit-Reset', String(reset))
|
||||||
|
|
||||||
|
// console.log(`Rate limit check for ${key} on ${name}: ${state.count}/${limit} (remaining: ${remaining}, resets in ${reset}s)`)
|
||||||
|
|
||||||
|
if (state.count > limit) {
|
||||||
|
res.setHeader('Retry-After', String(reset))
|
||||||
|
throw new APIError(429, 'Too Many Requests: rate limit exceeded.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export const typedEndpoint = <N extends APIPath>(
|
export const typedEndpoint = <N extends APIPath>(
|
||||||
name: N,
|
name: N,
|
||||||
handler: APIHandler<N>
|
handler: APIHandler<N>
|
||||||
) => {
|
) => {
|
||||||
const {props: propSchema, authed: authRequired, method} = API[name]
|
const {props: propSchema, authed: authRequired, rateLimited = false, method} = API[name] as APISchema<N>
|
||||||
|
|
||||||
return async (req: Request, res: Response, next: NextFunction) => {
|
return async (req: Request, res: Response, next: NextFunction) => {
|
||||||
let authUser: AuthedUser | undefined = undefined
|
let authUser: AuthedUser | undefined = undefined
|
||||||
@@ -188,6 +240,15 @@ export const typedEndpoint = <N extends APIPath>(
|
|||||||
if (authRequired) return next(e)
|
if (authRequired) return next(e)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply rate limiting before invoking the handler
|
||||||
|
if (rateLimited) {
|
||||||
|
try {
|
||||||
|
checkRateLimit(String(name), req, res, authUser)
|
||||||
|
} catch (e) {
|
||||||
|
return next(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const props = {
|
const props = {
|
||||||
...(method === 'GET' ? req.query : req.body),
|
...(method === 'GET' ? req.query : req.body),
|
||||||
...req.params,
|
...req.params,
|
||||||
@@ -211,7 +272,7 @@ export const typedEndpoint = <N extends APIPath>(
|
|||||||
if (!res.headersSent) {
|
if (!res.headersSent) {
|
||||||
// Convert bigint to number, b/c JSON doesn't support bigint.
|
// Convert bigint to number, b/c JSON doesn't support bigint.
|
||||||
const convertedResult = deepConvertBigIntToNumber(result)
|
const convertedResult = deepConvertBigIntToNumber(result)
|
||||||
|
// console.debug('API result', convertedResult)
|
||||||
res.status(200).json(convertedResult ?? {success: true})
|
res.status(200).json(convertedResult ?? {success: true})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
260
backend/api/src/helpers/private-messages.ts
Normal file
260
backend/api/src/helpers/private-messages.ts
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ import { createSupabaseDirectClient } from 'shared/supabase/init'
|
|||||||
import {
|
import {
|
||||||
insertPrivateMessage,
|
insertPrivateMessage,
|
||||||
leaveChatContent,
|
leaveChatContent,
|
||||||
} from 'api/junk-drawer/private-messages'
|
} from 'api/helpers/private-messages'
|
||||||
|
|
||||||
export const leavePrivateUserMessageChannel: APIHandler<
|
export const leavePrivateUserMessageChannel: APIHandler<
|
||||||
'leave-private-user-message-channel'
|
'leave-private-user-message-channel'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||||
import { APIError, APIHandler } from './helpers/endpoint'
|
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 { getHasFreeLike } from './has-free-like'
|
||||||
import { log } from 'shared/utils'
|
import { log } from 'shared/utils'
|
||||||
import { tryCatch } from 'common/util/try-catch'
|
import { tryCatch } from 'common/util/try-catch'
|
||||||
@@ -15,7 +15,7 @@ export const likeProfile: APIHandler<'like-profile'> = async (props, auth) => {
|
|||||||
if (remove) {
|
if (remove) {
|
||||||
const { error } = await tryCatch(
|
const { error } = await tryCatch(
|
||||||
pg.none(
|
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]
|
[creatorId, targetUserId]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -28,8 +28,8 @@ export const likeProfile: APIHandler<'like-profile'> = async (props, auth) => {
|
|||||||
|
|
||||||
// Check if like already exists
|
// Check if like already exists
|
||||||
const { data: existing } = await tryCatch(
|
const { data: existing } = await tryCatch(
|
||||||
pg.oneOrNone<Row<'love_likes'>>(
|
pg.oneOrNone<Row<'profile_likes'>>(
|
||||||
'select * from love_likes where creator_id = $1 and target_id = $2',
|
'select * from profile_likes where creator_id = $1 and target_id = $2',
|
||||||
[creatorId, targetUserId]
|
[creatorId, targetUserId]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -48,8 +48,8 @@ export const likeProfile: APIHandler<'like-profile'> = async (props, auth) => {
|
|||||||
|
|
||||||
// Insert the new like
|
// Insert the new like
|
||||||
const { data, error } = await tryCatch(
|
const { data, error } = await tryCatch(
|
||||||
pg.one<Row<'love_likes'>>(
|
pg.one<Row<'profile_likes'>>(
|
||||||
'insert into love_likes (creator_id, target_id) values ($1, $2) returning *',
|
'insert into profile_likes (creator_id, target_id) values ($1, $2) returning *',
|
||||||
[creatorId, targetUserId]
|
[creatorId, targetUserId]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -59,7 +59,7 @@ export const likeProfile: APIHandler<'like-profile'> = async (props, auth) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const continuation = async () => {
|
const continuation = async () => {
|
||||||
await createLoveLikeNotification(data)
|
await createProfileLikeNotification(data)
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import { APIError, APIHandler } from './helpers/endpoint'
|
import {APIError, APIHandler} from './helpers/endpoint'
|
||||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
import { tryCatch } from 'common/util/try-catch'
|
import {tryCatch} from 'common/util/try-catch'
|
||||||
import { insert } from 'shared/supabase/utils'
|
import {insert} from 'shared/supabase/utils'
|
||||||
|
import {sendDiscordMessage} from "common/discord/core";
|
||||||
|
import {Row} from "common/supabase/utils";
|
||||||
|
import {DOMAIN} from "common/envs/constants";
|
||||||
|
|
||||||
// abusable: people can report the wrong person, that didn't write the comment
|
// abusable: people can report the wrong person, that didn't write the comment
|
||||||
// but in practice we check it manually and nothing bad happens to them automatically
|
// but in practice we check it manually and nothing bad happens to them automatically
|
||||||
@@ -33,5 +36,38 @@ export const report: APIHandler<'report'> = async (body, auth) => {
|
|||||||
throw new APIError(500, 'Failed to create report: ' + result.error.message)
|
throw new APIError(500, 'Failed to create report: ' + result.error.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
return { success: true }
|
const continuation = async () => {
|
||||||
|
try {
|
||||||
|
const {data: reporter, error} = await tryCatch(
|
||||||
|
pg.oneOrNone<Row<'users'>>('select * from users where id = $1', [auth.uid])
|
||||||
|
)
|
||||||
|
if (error) {
|
||||||
|
console.error('Failed to get user for report', error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const {data: reported, error: userError} = await tryCatch(
|
||||||
|
pg.oneOrNone<Row<'users'>>('select * from users where id = $1', [contentOwnerId])
|
||||||
|
)
|
||||||
|
if (userError) {
|
||||||
|
console.error('Failed to get reported user for report', userError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let message: string = `
|
||||||
|
🚨 **New Report** 🚨
|
||||||
|
**Type:** ${contentType}
|
||||||
|
**Content ID:** ${contentId}
|
||||||
|
**Reporter:** ${reporter?.name} ([@${reporter?.username}](https://www.${DOMAIN}/${reporter?.username}))
|
||||||
|
**Reported:** ${reported?.name} ([@${reported?.username}](https://www.${DOMAIN}/${reported?.username}))
|
||||||
|
`
|
||||||
|
await sendDiscordMessage(message, 'reports')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to send discord reports', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: {},
|
||||||
|
continue: continuation,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
backend/api/src/save-subscription.ts
Normal file
41
backend/api/src/save-subscription.ts
Normal 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`)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,15 +20,15 @@ export const searchUsers: APIHandler<'search-users'> = async (props, auth) => {
|
|||||||
const pg = createSupabaseDirectClient()
|
const pg = createSupabaseDirectClient()
|
||||||
|
|
||||||
const offset = page * limit
|
const offset = page * limit
|
||||||
const userId = auth?.uid
|
// const userId = auth?.uid
|
||||||
const searchFollowersSQL = getSearchUserSQL({ term, offset, limit, userId })
|
// const searchFollowersSQL = getSearchUserSQL({ term, offset, limit, userId })
|
||||||
const searchAllSQL = getSearchUserSQL({ term, offset, limit })
|
const searchAllSQL = getSearchUserSQL({ term, offset, limit })
|
||||||
const [followers, all] = await Promise.all([
|
const [all] = await Promise.all([
|
||||||
pg.map(searchFollowersSQL, null, convertUser),
|
// pg.map(searchFollowersSQL, null, convertUser),
|
||||||
pg.map(searchAllSQL, null, convertUser),
|
pg.map(searchAllSQL, null, convertUser),
|
||||||
])
|
])
|
||||||
|
|
||||||
return uniqBy([...followers, ...all], 'id')
|
return uniqBy([...all], 'id')
|
||||||
.map(toUserAPIResponse)
|
.map(toUserAPIResponse)
|
||||||
.slice(0, limit)
|
.slice(0, limit)
|
||||||
}
|
}
|
||||||
@@ -39,17 +39,18 @@ function getSearchUserSQL(props: {
|
|||||||
limit: number
|
limit: number
|
||||||
userId?: string // search only this user's followers
|
userId?: string // search only this user's followers
|
||||||
}) {
|
}) {
|
||||||
const { term, userId } = props
|
const { term } = props
|
||||||
|
|
||||||
return renderSql(
|
return renderSql(
|
||||||
userId
|
// userId
|
||||||
? [
|
// ? [
|
||||||
select('users.*'),
|
// select('users.*'),
|
||||||
from('users'),
|
// from('users'),
|
||||||
join('user_follows on user_follows.follow_id = users.id'),
|
// join('user_follows on user_follows.follow_id = users.id'),
|
||||||
where('user_follows.user_id = $1', [userId]),
|
// where('user_follows.user_id = $1', [userId]),
|
||||||
]
|
// ]
|
||||||
: [select('*'), from('users')],
|
// :
|
||||||
|
[select('*'), from('users')],
|
||||||
term
|
term
|
||||||
? [
|
? [
|
||||||
where(
|
where(
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {from, renderSql, select} from "shared/supabase/sql-builder";
|
|||||||
import {loadProfiles, profileQueryType} from "api/get-profiles";
|
import {loadProfiles, profileQueryType} from "api/get-profiles";
|
||||||
import {Row} from "common/supabase/utils";
|
import {Row} from "common/supabase/utils";
|
||||||
import {sendSearchAlertsEmail} from "email/functions/helpers";
|
import {sendSearchAlertsEmail} from "email/functions/helpers";
|
||||||
import {MatchesByUserType} from "common/love/bookmarked_searches";
|
import {MatchesByUserType} from "common/profiles/bookmarked_searches";
|
||||||
import {keyBy} from "lodash";
|
import {keyBy} from "lodash";
|
||||||
|
|
||||||
export function convertSearchRow(row: any): any {
|
export function convertSearchRow(row: any): any {
|
||||||
@@ -53,8 +53,9 @@ export const sendSearchNotifications = async () => {
|
|||||||
|
|
||||||
for (const row of searches) {
|
for (const row of searches) {
|
||||||
if (typeof row.search_filters !== 'object') continue;
|
if (typeof row.search_filters !== 'object') continue;
|
||||||
|
const { orderBy, ...filters } = (row.search_filters ?? {}) as Record<string, any>
|
||||||
const props = {
|
const props = {
|
||||||
...row.search_filters,
|
...filters,
|
||||||
skipId: row.creator_id,
|
skipId: row.creator_id,
|
||||||
lastModificationWithin: '24 hours',
|
lastModificationWithin: '24 hours',
|
||||||
shortBio: true,
|
shortBio: true,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import "tsconfig-paths/register";
|
||||||
import * as admin from 'firebase-admin'
|
import * as admin from 'firebase-admin'
|
||||||
import {initAdmin} from 'shared/init-admin'
|
import {initAdmin} from 'shared/init-admin'
|
||||||
import {loadSecretsToEnv} from 'common/secrets'
|
import {loadSecretsToEnv} from 'common/secrets'
|
||||||
|
|||||||
34
backend/api/src/set-compatibility-answer.ts
Normal file
34
backend/api/src/set-compatibility-answer.ts
Normal 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
|
||||||
|
}
|
||||||
26
backend/api/src/set-last-online-time.ts
Normal file
26
backend/api/src/set-last-online-time.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import {APIHandler} from './helpers/endpoint'
|
||||||
|
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||||
|
|
||||||
|
export const setLastOnlineTime: APIHandler<'set-last-online-time'> = async (
|
||||||
|
_,
|
||||||
|
auth
|
||||||
|
) => {
|
||||||
|
if (!auth || !auth.uid) return
|
||||||
|
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)
|
||||||
|
VALUES ($1, now())
|
||||||
|
ON CONFLICT (user_id)
|
||||||
|
DO UPDATE
|
||||||
|
SET last_online_time = EXCLUDED.last_online_time
|
||||||
|
WHERE user_activity.last_online_time < now() - interval '1 minute';
|
||||||
|
`,
|
||||||
|
[userId]
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||||
import { APIError, APIHandler } from './helpers/endpoint'
|
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 { log } from 'shared/utils'
|
||||||
import { tryCatch } from 'common/util/try-catch'
|
import { tryCatch } from 'common/util/try-catch'
|
||||||
import { insert } from 'shared/supabase/utils'
|
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
|
// Check if ship already exists or with swapped target IDs
|
||||||
const existing = await tryCatch(
|
const existing = await tryCatch(
|
||||||
pg.oneOrNone<{ ship_id: string }>(
|
pg.oneOrNone<{ ship_id: string }>(
|
||||||
`select ship_id from love_ships
|
`select ship_id from profile_ships
|
||||||
where creator_id = $1
|
where creator_id = $1
|
||||||
and (
|
and (
|
||||||
target1_id = $2 and target2_id = $3
|
target1_id = $2 and target2_id = $3
|
||||||
@@ -33,7 +33,7 @@ export const shipProfiles: APIHandler<'ship-profiles'> = async (props, auth) =>
|
|||||||
if (existing.data) {
|
if (existing.data) {
|
||||||
if (remove) {
|
if (remove) {
|
||||||
const { error } = await tryCatch(
|
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,
|
existing.data.ship_id,
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
@@ -48,7 +48,7 @@ export const shipProfiles: APIHandler<'ship-profiles'> = async (props, auth) =>
|
|||||||
|
|
||||||
// Insert the new ship
|
// Insert the new ship
|
||||||
const { data, error } = await tryCatch(
|
const { data, error } = await tryCatch(
|
||||||
insert(pg, 'love_ships', {
|
insert(pg, 'profile_ships', {
|
||||||
creator_id: creatorId,
|
creator_id: creatorId,
|
||||||
target1_id: targetUserId1,
|
target1_id: targetUserId1,
|
||||||
target2_id: targetUserId2,
|
target2_id: targetUserId2,
|
||||||
@@ -61,8 +61,8 @@ export const shipProfiles: APIHandler<'ship-profiles'> = async (props, auth) =>
|
|||||||
|
|
||||||
const continuation = async () => {
|
const continuation = async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
createLoveShipNotification(data, data.target1_id),
|
createProfileShipNotification(data, data.target1_id),
|
||||||
createLoveShipNotification(data, data.target2_id),
|
createProfileShipNotification(data, data.target2_id),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const starProfile: APIHandler<'star-profile'> = async (props, auth) => {
|
|||||||
if (remove) {
|
if (remove) {
|
||||||
const { error } = await tryCatch(
|
const { error } = await tryCatch(
|
||||||
pg.none(
|
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]
|
[creatorId, targetUserId]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -27,8 +27,8 @@ export const starProfile: APIHandler<'star-profile'> = async (props, auth) => {
|
|||||||
|
|
||||||
// Check if star already exists
|
// Check if star already exists
|
||||||
const { data: existing } = await tryCatch(
|
const { data: existing } = await tryCatch(
|
||||||
pg.oneOrNone<Row<'love_stars'>>(
|
pg.oneOrNone<Row<'profile_stars'>>(
|
||||||
'select * from love_stars where creator_id = $1 and target_id = $2',
|
'select * from profile_stars where creator_id = $1 and target_id = $2',
|
||||||
[creatorId, targetUserId]
|
[creatorId, targetUserId]
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -40,7 +40,7 @@ export const starProfile: APIHandler<'star-profile'> = async (props, auth) => {
|
|||||||
|
|
||||||
// Insert the new star
|
// Insert the new star
|
||||||
const { error } = await tryCatch(
|
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) {
|
if (error) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { APIError, APIHandler } from 'api/helpers/endpoint'
|
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 { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||||
import { updateUser } from 'shared/supabase/users'
|
import { updateUser } from 'shared/supabase/users'
|
||||||
import { log } from 'shared/utils'
|
import { log } from 'shared/utils'
|
||||||
@@ -24,8 +24,7 @@ export const updateProfile: APIHandler<'update-profile'> = async (
|
|||||||
throw new APIError(404, 'Profile not found')
|
throw new APIError(404, 'Profile not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
!parsedBody.last_online_time &&
|
log('Updating profile', { userId: auth.uid, parsedBody })
|
||||||
log('Updating profile', { userId: auth.uid, parsedBody })
|
|
||||||
|
|
||||||
await removePinnedUrlFromPhotoUrls(parsedBody)
|
await removePinnedUrlFromPhotoUrls(parsedBody)
|
||||||
if (parsedBody.avatar_url) {
|
if (parsedBody.avatar_url) {
|
||||||
|
|||||||
39
backend/api/src/vote.ts
Normal file
39
backend/api/src/vote.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||||
|
import { getUser } from 'shared/utils'
|
||||||
|
import { APIHandler, APIError } from './helpers/endpoint'
|
||||||
|
|
||||||
|
export const vote: APIHandler<'vote'> = async ({ voteId, choice, priority }, auth) => {
|
||||||
|
const user = await getUser(auth.uid)
|
||||||
|
if (!user) throw new APIError(401, 'Your account was not found')
|
||||||
|
|
||||||
|
const pg = createSupabaseDirectClient()
|
||||||
|
|
||||||
|
// Map string choice to smallint (-1, 0, 1)
|
||||||
|
const choiceMap: Record<string, number> = {
|
||||||
|
'for': 1,
|
||||||
|
'abstain': 0,
|
||||||
|
'against': -1,
|
||||||
|
}
|
||||||
|
const choiceVal = choiceMap[choice]
|
||||||
|
if (choiceVal === undefined) {
|
||||||
|
throw new APIError(400, 'Invalid choice')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upsert the vote result to ensure one vote per user per vote
|
||||||
|
// Assuming table vote_results with unique (user_id, vote_id)
|
||||||
|
const query = `
|
||||||
|
insert into vote_results (user_id, vote_id, choice, priority)
|
||||||
|
values ($1, $2, $3, $4)
|
||||||
|
on conflict (user_id, vote_id)
|
||||||
|
do update set choice = excluded.choice,
|
||||||
|
priority = excluded.priority
|
||||||
|
returning *;
|
||||||
|
`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await pg.one(query, [user.id, voteId, choiceVal, priority])
|
||||||
|
return { data: result }
|
||||||
|
} catch (e) {
|
||||||
|
throw new APIError(500, 'Error recording vote', e as any)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,8 @@ set -e
|
|||||||
SERVICE_NAME="api"
|
SERVICE_NAME="api"
|
||||||
SERVICE_GROUP="${SERVICE_NAME}-group"
|
SERVICE_GROUP="${SERVICE_NAME}-group"
|
||||||
ZONE="us-west1-c"
|
ZONE="us-west1-c"
|
||||||
ENV=${1:-dev}
|
#ENV=${1:-dev}
|
||||||
|
ENV=prod
|
||||||
|
|
||||||
case $ENV in
|
case $ENV in
|
||||||
dev)
|
dev)
|
||||||
@@ -28,10 +29,19 @@ INSTANCE_ID=$(gcloud compute instances list \
|
|||||||
--format="value(name)" \
|
--format="value(name)" \
|
||||||
--limit=1)
|
--limit=1)
|
||||||
|
|
||||||
echo "Forwarding debugging port 9229 to ${INSTANCE_ID}. Open chrome://inspect in Chrome to connect."
|
#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} \
|
if [ "$1" = "logs" ]; then
|
||||||
--project=${GCLOUD_PROJECT} \
|
CMD=(--command="sudo docker logs -f \$(sudo docker ps -alq)")
|
||||||
--zone=${ZONE} \
|
else
|
||||||
|
CMD=()
|
||||||
|
fi
|
||||||
|
|
||||||
|
gcloud compute ssh "${INSTANCE_ID}" \
|
||||||
|
--project="${GCLOUD_PROJECT}" \
|
||||||
|
--zone="${ZONE}" \
|
||||||
|
"${CMD[@]}"
|
||||||
|
|
||||||
# -- \
|
# -- \
|
||||||
# -NL 9229:localhost:9229
|
# -NL 9229:localhost:9229
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"tsBuildInfoFile": "lib/tsconfig.tsbuildinfo",
|
"tsBuildInfoFile": "lib/tsconfig.tsbuildinfo",
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"target": "esnext",
|
"target": "esnext",
|
||||||
@@ -50,6 +51,7 @@
|
|||||||
"compileOnSave": true,
|
"compileOnSave": true,
|
||||||
"include": [
|
"include": [
|
||||||
"src/**/*.ts",
|
"src/**/*.ts",
|
||||||
"openapi.json"
|
"package.json",
|
||||||
|
"backend/api/package.json"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,13 @@ import {sendEmail} from './send-email'
|
|||||||
import {NewMessageEmail} from '../new-message'
|
import {NewMessageEmail} from '../new-message'
|
||||||
import {NewEndorsementEmail} from '../new-endorsement'
|
import {NewEndorsementEmail} from '../new-endorsement'
|
||||||
import {Test} from '../test'
|
import {Test} from '../test'
|
||||||
import {getProfile} from 'shared/love/supabase'
|
import {getProfile} from 'shared/profiles/supabase'
|
||||||
import { render } from "@react-email/render"
|
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 NewSearchAlertsEmail from "email/new-search_alerts";
|
||||||
import WelcomeEmail from "email/welcome";
|
import WelcomeEmail from "email/welcome";
|
||||||
|
|
||||||
const from = 'Compass <no-reply@compassmeet.com>'
|
const from = 'Compass <compass@compassmeet.com>'
|
||||||
|
|
||||||
// export const sendNewMatchEmail = async (
|
// export const sendNewMatchEmail = async (
|
||||||
// privateUser: PrivateUser,
|
// privateUser: PrivateUser,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import {ProfileRow} from 'common/love/profile'
|
import {ProfileRow} from 'common/profiles/profile'
|
||||||
import type {User} from 'common/user'
|
import type {User} from 'common/user'
|
||||||
|
|
||||||
// for email template testing
|
// for email template testing
|
||||||
@@ -17,7 +17,6 @@ export const mockUser: User = {
|
|||||||
id: '0k1suGSJKVUnHbCPEhHNpgZPkUP2',
|
id: '0k1suGSJKVUnHbCPEhHNpgZPkUP2',
|
||||||
username: 'Sinclair',
|
username: 'Sinclair',
|
||||||
name: 'Sinclair',
|
name: 'Sinclair',
|
||||||
// url: 'https://manifold.love/Sinclair',
|
|
||||||
// isAdmin: true,
|
// isAdmin: true,
|
||||||
// isTrustworthy: false,
|
// isTrustworthy: false,
|
||||||
link: {
|
link: {
|
||||||
@@ -31,14 +30,14 @@ export const sinclairProfile: ProfileRow = {
|
|||||||
id: 55,
|
id: 55,
|
||||||
user_id: '0k1suGSJKVUnHbCPEhHNpgZPkUP2',
|
user_id: '0k1suGSJKVUnHbCPEhHNpgZPkUP2',
|
||||||
created_time: '2023-10-27T00:41:59.851776+00:00',
|
created_time: '2023-10-27T00:41:59.851776+00:00',
|
||||||
last_online_time: '2024-05-17T02:11:48.83+00:00',
|
|
||||||
last_modification_time: '2024-05-17T02:11:48.83+00:00',
|
last_modification_time: '2024-05-17T02:11:48.83+00:00',
|
||||||
city: 'San Francisco',
|
city: 'San Francisco',
|
||||||
gender: 'trans-female',
|
gender: 'trans-female',
|
||||||
pref_gender: ['female', 'trans-female'],
|
pref_gender: ['female', 'trans-female'],
|
||||||
pref_age_min: 18,
|
pref_age_min: 18,
|
||||||
pref_age_max: 21,
|
pref_age_max: 21,
|
||||||
pref_relation_styles: ['poly', 'open', 'mono'],
|
pref_relation_styles: ['friendship'],
|
||||||
|
pref_romantic_styles: ['poly', 'open', 'mono'],
|
||||||
wants_kids_strength: 3,
|
wants_kids_strength: 3,
|
||||||
looking_for_matches: true,
|
looking_for_matches: true,
|
||||||
visibility: 'public',
|
visibility: 'public',
|
||||||
@@ -47,7 +46,7 @@ export const sinclairProfile: ProfileRow = {
|
|||||||
has_kids: 0,
|
has_kids: 0,
|
||||||
is_smoker: false,
|
is_smoker: false,
|
||||||
drinks_per_month: 0,
|
drinks_per_month: 0,
|
||||||
is_vegetarian_or_vegan: null,
|
diet: null,
|
||||||
political_beliefs: ['e/acc', 'libertarian'],
|
political_beliefs: ['e/acc', 'libertarian'],
|
||||||
religious_belief_strength: null,
|
religious_belief_strength: null,
|
||||||
religious_beliefs: null,
|
religious_beliefs: null,
|
||||||
@@ -132,14 +131,14 @@ export const jamesProfile: ProfileRow = {
|
|||||||
id: 2,
|
id: 2,
|
||||||
user_id: '5LZ4LgYuySdL1huCWe7bti02ghx2',
|
user_id: '5LZ4LgYuySdL1huCWe7bti02ghx2',
|
||||||
created_time: '2023-10-21T21:18:26.691211+00:00',
|
created_time: '2023-10-21T21:18:26.691211+00:00',
|
||||||
last_online_time: '2024-07-06T17:29:16.833+00:00',
|
|
||||||
last_modification_time: '2024-05-17T02:11:48.83+00:00',
|
last_modification_time: '2024-05-17T02:11:48.83+00:00',
|
||||||
city: 'San Francisco',
|
city: 'San Francisco',
|
||||||
gender: 'male',
|
gender: 'male',
|
||||||
pref_gender: ['female'],
|
pref_gender: ['female'],
|
||||||
pref_age_min: 22,
|
pref_age_min: 22,
|
||||||
pref_age_max: 32,
|
pref_age_max: 32,
|
||||||
pref_relation_styles: ['mono'],
|
pref_relation_styles: ['friendship'],
|
||||||
|
pref_romantic_styles: ['poly', 'open', 'mono'],
|
||||||
wants_kids_strength: 4,
|
wants_kids_strength: 4,
|
||||||
looking_for_matches: true,
|
looking_for_matches: true,
|
||||||
visibility: 'public',
|
visibility: 'public',
|
||||||
@@ -148,7 +147,7 @@ export const jamesProfile: ProfileRow = {
|
|||||||
has_kids: 0,
|
has_kids: 0,
|
||||||
is_smoker: false,
|
is_smoker: false,
|
||||||
drinks_per_month: 5,
|
drinks_per_month: 5,
|
||||||
is_vegetarian_or_vegan: null,
|
diet: null,
|
||||||
political_beliefs: ['libertarian'],
|
political_beliefs: ['libertarian'],
|
||||||
religious_belief_strength: null,
|
religious_belief_strength: null,
|
||||||
religious_beliefs: '',
|
religious_beliefs: '',
|
||||||
|
|||||||
@@ -4,10 +4,8 @@ import {
|
|||||||
type CreateEmailOptions,
|
type CreateEmailOptions,
|
||||||
} from 'resend'
|
} from 'resend'
|
||||||
import { log } from 'shared/utils'
|
import { log } from 'shared/utils'
|
||||||
|
import {sleep} from "common/util/time";
|
||||||
|
|
||||||
import pLimit from 'p-limit'
|
|
||||||
|
|
||||||
const limit = pLimit(1) // 1 concurrent per second
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* typically: { subject: string, to: string | string[] } & ({ text: string } | { react: ReactNode })
|
* typically: { subject: string, to: string | string[] } & ({ text: string } | { react: ReactNode })
|
||||||
@@ -19,13 +17,10 @@ export const sendEmail = async (
|
|||||||
const resend = getResend()
|
const resend = getResend()
|
||||||
console.debug(resend, payload, options)
|
console.debug(resend, payload, options)
|
||||||
|
|
||||||
async function sendEmailThrottle(data: any, options: any) {
|
if (!resend) return null
|
||||||
if (!resend) return { data: null, error: 'No Resend client' }
|
|
||||||
return limit(() => resend.emails.send(data, options))
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data, error } = await sendEmailThrottle(
|
const { data, error } = await resend.emails.send(
|
||||||
{ replyTo: 'Compass <no-reply@compassmeet.com>', ...payload },
|
{ replyTo: 'Compass <hello@compassmeet.com>', ...payload },
|
||||||
options
|
options
|
||||||
)
|
)
|
||||||
console.debug('resend.emails.send', data, error)
|
console.debug('resend.emails.send', data, error)
|
||||||
@@ -39,6 +34,9 @@ export const sendEmail = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
log(`Sent email to ${payload.to} with subject ${payload.subject}`)
|
log(`Sent email to ${payload.to} with subject ${payload.subject}`)
|
||||||
|
|
||||||
|
await sleep(1000) // to avoid rate limits (2 / second in resend free plan)
|
||||||
|
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {Body, Button, Container, Head, Html, Preview, Section, Text,} from '@react-email/components'
|
import {Body, Button, Container, Head, Html, Preview, Section, Text,} from '@react-email/components'
|
||||||
import {DOMAIN} from 'common/envs/constants'
|
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 {type User} from 'common/user'
|
||||||
import {jamesProfile, jamesUser, mockUser} from './functions/mock'
|
import {jamesProfile, jamesUser, mockUser} from './functions/mock'
|
||||||
import {Footer} from "email/utils";
|
import {Footer} from "email/utils";
|
||||||
@@ -21,7 +21,7 @@ export const NewMatchEmail = ({
|
|||||||
email
|
email
|
||||||
}: NewMatchEmailProps) => {
|
}: NewMatchEmailProps) => {
|
||||||
const name = onUser.name.split(' ')[0]
|
const name = onUser.name.split(' ')[0]
|
||||||
// const userImgSrc = getLoveOgImageUrl(matchedWithUser, matchedProfile)
|
// const userImgSrc = getOgImageUrl(matchedWithUser, matchedProfile)
|
||||||
const userUrl = `https://${DOMAIN}/${matchedWithUser.username}`
|
const userUrl = `https://${DOMAIN}/${matchedWithUser.username}`
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {Body, Button, Container, Head, Html, Preview, Section, Text,} from '@react-email/components'
|
import {Body, Button, Container, Head, Html, Preview, Section, Text,} from '@react-email/components'
|
||||||
import {type User} from 'common/user'
|
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 {jamesProfile, jamesUser, mockUser,} from './functions/mock'
|
||||||
import {DOMAIN} from 'common/envs/constants'
|
import {DOMAIN} from 'common/envs/constants'
|
||||||
import {button, container, content, Footer, imageContainer, main, paragraph} from "email/utils";
|
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 name = toUser.name.split(' ')[0]
|
||||||
const creatorName = fromUser.name
|
const creatorName = fromUser.name
|
||||||
const messagesUrl = `https://${DOMAIN}/messages/${channelId}`
|
const messagesUrl = `https://${DOMAIN}/messages/${channelId}`
|
||||||
// const userImgSrc = getLoveOgImageUrl(fromUser, fromUserProfile)
|
// const userImgSrc = getOgImageUrl(fromUser, fromUserProfile)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Html>
|
<Html>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {type User} from 'common/user'
|
|||||||
import {mockUser,} from './functions/mock'
|
import {mockUser,} from './functions/mock'
|
||||||
import {DOMAIN} from 'common/envs/constants'
|
import {DOMAIN} from 'common/envs/constants'
|
||||||
import {container, content, Footer, main, paragraph} from "email/utils";
|
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 {formatFilters, locationType} from "common/searches"
|
||||||
import {FilterFields} from "common/filters";
|
import {FilterFields} from "common/filters";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {Column, Img, Link, Row, Section, Text} from "@react-email/components";
|
import {Column, Img, Link, Row, Section, Text} from "@react-email/components";
|
||||||
import {discordLink, githubRepo, patreonLink, paypalLink} from "common/constants";
|
|
||||||
import {DOMAIN} from "common/envs/constants";
|
import {DOMAIN} from "common/envs/constants";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -15,40 +14,40 @@ export const Footer = ({
|
|||||||
<hr style={{border: 'none', borderTop: '1px solid #e0e0e0', margin: '10px 0'}}/>
|
<hr style={{border: 'none', borderTop: '1px solid #e0e0e0', margin: '10px 0'}}/>
|
||||||
<Row>
|
<Row>
|
||||||
<Column align="center">
|
<Column align="center">
|
||||||
<Link href={githubRepo} target="_blank">
|
<Link href={`https://${DOMAIN}/github`} target="_blank">
|
||||||
<Img
|
<Img
|
||||||
src={`https://${DOMAIN}/images/github-logo.png`}
|
src={`https://${DOMAIN}/images/github-logo.png`}
|
||||||
width="24"
|
width="24"
|
||||||
height="24"
|
height="24"
|
||||||
alt="GitHub"
|
alt="GitHub"
|
||||||
style={{ display: "inline-block", margin: "0 4px" }}
|
style={{display: "inline-block", margin: "0 4px"}}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href={discordLink} target="_blank">
|
<Link href={`https://${DOMAIN}/discord`} target="_blank">
|
||||||
<Img
|
<Img
|
||||||
src={`https://${DOMAIN}/images/discord-logo.png`}
|
src={`https://${DOMAIN}/images/discord-logo.png`}
|
||||||
width="24"
|
width="24"
|
||||||
height="24"
|
height="24"
|
||||||
alt="Discord"
|
alt="Discord"
|
||||||
style={{ display: "inline-block", margin: "0 4px" }}
|
style={{display: "inline-block", margin: "0 4px"}}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href={patreonLink} target="_blank">
|
<Link href={`https://${DOMAIN}/patreon`} target="_blank">
|
||||||
<Img
|
<Img
|
||||||
src={`https://${DOMAIN}/images/patreon-logo.png`}
|
src={`https://${DOMAIN}/images/patreon-logo.png`}
|
||||||
width="24"
|
width="24"
|
||||||
height="24"
|
height="24"
|
||||||
alt="Patreon"
|
alt="Patreon"
|
||||||
style={{ display: "inline-block", margin: "0 4px" }}
|
style={{display: "inline-block", margin: "0 4px"}}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<Link href={paypalLink} target="_blank">
|
<Link href={`https://${DOMAIN}/paypal`} target="_blank">
|
||||||
<Img
|
<Img
|
||||||
src={`https://${DOMAIN}/images/paypal-logo.png`}
|
src={`https://${DOMAIN}/images/paypal-logo.png`}
|
||||||
width="24"
|
width="24"
|
||||||
height="24"
|
height="24"
|
||||||
alt="PayPal"
|
alt="PayPal"
|
||||||
style={{ display: "inline-block", margin: "0 4px" }}
|
style={{display: "inline-block", margin: "0 4px"}}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
</Column>
|
</Column>
|
||||||
|
|||||||
@@ -14,10 +14,6 @@ select
|
|||||||
from
|
from
|
||||||
temp_users;
|
temp_users;
|
||||||
|
|
||||||
-- Rename temp_love_messages
|
|
||||||
-- alter table temp_love_messages
|
|
||||||
-- rename to private_user_messages;
|
|
||||||
|
|
||||||
-- alter table private_user_messages
|
-- alter table private_user_messages
|
||||||
-- alter column channel_id set not null,
|
-- alter column channel_id set not null,
|
||||||
-- alter column content set not null,
|
-- alter column content set not null,
|
||||||
@@ -12,7 +12,7 @@ DB_USER="postgres"
|
|||||||
PORT="5432"
|
PORT="5432"
|
||||||
|
|
||||||
psql -U $DB_USER -d postgres -h $DB_NAME -p $PORT -w \
|
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 \
|
# 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/private_users.sql \
|
||||||
# -f ../supabase/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"
|
echo "Done"
|
||||||
)
|
)
|
||||||
@@ -1,19 +1,24 @@
|
|||||||
import {initAdmin} from 'shared/init-admin'
|
import {initAdmin} from 'shared/init-admin'
|
||||||
import {loadSecretsToEnv} from 'common/secrets'
|
|
||||||
import {createSupabaseDirectClient, type SupabaseDirectClient,} from 'shared/supabase/init'
|
import {createSupabaseDirectClient, type SupabaseDirectClient,} from 'shared/supabase/init'
|
||||||
import {getServiceAccountCredentials} from "shared/firebase-utils";
|
import {refreshConfig} from "common/envs/prod";
|
||||||
|
|
||||||
initAdmin()
|
|
||||||
|
|
||||||
export const runScript = async (
|
export const runScript = async (
|
||||||
main: (services: { pg: SupabaseDirectClient }) => Promise<any> | any
|
main: (services: { pg: SupabaseDirectClient }) => Promise<any> | any
|
||||||
) => {
|
) => {
|
||||||
const credentials = getServiceAccountCredentials()
|
initAdmin()
|
||||||
|
await initEnvVariables()
|
||||||
await loadSecretsToEnv(credentials)
|
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()
|
const pg = createSupabaseDirectClient()
|
||||||
|
console.debug('runScript: running main...')
|
||||||
await main({pg})
|
await main({pg})
|
||||||
|
}
|
||||||
process.exit()
|
|
||||||
|
|
||||||
|
export async function initEnvVariables() {
|
||||||
|
const {config} = await import('dotenv')
|
||||||
|
config({ path: __dirname + '/../../.env' })
|
||||||
|
refreshConfig()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { createSupabaseDirectClient } from './supabase/init'
|
|||||||
import { getNotificationDestinationsForUser } from 'common/user-notification-preferences'
|
import { getNotificationDestinationsForUser } from 'common/user-notification-preferences'
|
||||||
import { Notification } from 'common/notifications'
|
import { Notification } from 'common/notifications'
|
||||||
import { insertNotificationToSupabase } from './supabase/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 { creator_id, target_id, like_id } = like
|
||||||
|
|
||||||
const targetPrivateUser = await getPrivateUser(target_id)
|
const targetPrivateUser = await getPrivateUser(target_id)
|
||||||
@@ -16,7 +16,7 @@ export const createLoveLikeNotification = async (like: Row<'love_likes'>) => {
|
|||||||
|
|
||||||
const { sendToBrowser } = getNotificationDestinationsForUser(
|
const { sendToBrowser } = getNotificationDestinationsForUser(
|
||||||
targetPrivateUser,
|
targetPrivateUser,
|
||||||
'new_love_like'
|
'new_profile_like'
|
||||||
)
|
)
|
||||||
if (!sendToBrowser) return
|
if (!sendToBrowser) return
|
||||||
|
|
||||||
@@ -24,11 +24,11 @@ export const createLoveLikeNotification = async (like: Row<'love_likes'>) => {
|
|||||||
const notification: Notification = {
|
const notification: Notification = {
|
||||||
id,
|
id,
|
||||||
userId: target_id,
|
userId: target_id,
|
||||||
reason: 'new_love_like',
|
reason: 'new_profile_like',
|
||||||
createdTime: Date.now(),
|
createdTime: Date.now(),
|
||||||
isSeen: false,
|
isSeen: false,
|
||||||
sourceId: like_id,
|
sourceId: like_id,
|
||||||
sourceType: 'love_like',
|
sourceType: 'profile_like',
|
||||||
sourceUpdateType: 'created',
|
sourceUpdateType: 'created',
|
||||||
sourceUserName: profile.user.name,
|
sourceUserName: profile.user.name,
|
||||||
sourceUserUsername: profile.user.username,
|
sourceUserUsername: profile.user.username,
|
||||||
@@ -39,8 +39,8 @@ export const createLoveLikeNotification = async (like: Row<'love_likes'>) => {
|
|||||||
return await insertNotificationToSupabase(notification, pg)
|
return await insertNotificationToSupabase(notification, pg)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createLoveShipNotification = async (
|
export const createProfileShipNotification = async (
|
||||||
ship: Row<'love_ships'>,
|
ship: Row<'profile_ships'>,
|
||||||
recipientId: string
|
recipientId: string
|
||||||
) => {
|
) => {
|
||||||
const { creator_id, target1_id, target2_id, ship_id } = ship
|
const { creator_id, target1_id, target2_id, ship_id } = ship
|
||||||
@@ -61,7 +61,7 @@ export const createLoveShipNotification = async (
|
|||||||
|
|
||||||
const { sendToBrowser } = getNotificationDestinationsForUser(
|
const { sendToBrowser } = getNotificationDestinationsForUser(
|
||||||
targetPrivateUser,
|
targetPrivateUser,
|
||||||
'new_love_ship'
|
'new_profile_ship'
|
||||||
)
|
)
|
||||||
if (!sendToBrowser) return
|
if (!sendToBrowser) return
|
||||||
|
|
||||||
@@ -69,11 +69,11 @@ export const createLoveShipNotification = async (
|
|||||||
const notification: Notification = {
|
const notification: Notification = {
|
||||||
id,
|
id,
|
||||||
userId: recipientId,
|
userId: recipientId,
|
||||||
reason: 'new_love_ship',
|
reason: 'new_profile_ship',
|
||||||
createdTime: Date.now(),
|
createdTime: Date.now(),
|
||||||
isSeen: false,
|
isSeen: false,
|
||||||
sourceId: ship_id,
|
sourceId: ship_id,
|
||||||
sourceType: 'love_ship',
|
sourceType: 'profile_ship',
|
||||||
sourceUpdateType: 'created',
|
sourceUpdateType: 'created',
|
||||||
sourceUserName: profile.user.name,
|
sourceUserName: profile.user.name,
|
||||||
sourceUserUsername: profile.user.username,
|
sourceUserUsername: profile.user.username,
|
||||||
58
backend/shared/src/encryption.ts
Normal file
58
backend/shared/src/encryption.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { areGenderCompatible } from 'common/love/compatibility-util'
|
import { areGenderCompatible } from 'common/profiles/compatibility-util'
|
||||||
import { type Profile, type ProfileRow } from 'common/love/profile'
|
import { type Profile, type ProfileRow } from 'common/profiles/profile'
|
||||||
import { type User } from 'common/user'
|
import { type User } from 'common/user'
|
||||||
import { Row } from 'common/supabase/utils'
|
import { Row } from 'common/supabase/utils'
|
||||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||||
@@ -24,14 +24,14 @@ export function convertRow(row: ProfileAndUserRow | undefined): Profile | null {
|
|||||||
return profile as Profile
|
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) => {
|
export const getProfile = async (userId: string) => {
|
||||||
const pg = createSupabaseDirectClient()
|
const pg = createSupabaseDirectClient()
|
||||||
return await pg.oneOrNone(
|
return await pg.oneOrNone(
|
||||||
`
|
`
|
||||||
select
|
select
|
||||||
${LOVER_COLS}
|
${PROFILE_COLS}
|
||||||
from
|
from
|
||||||
profiles
|
profiles
|
||||||
join
|
join
|
||||||
@@ -49,7 +49,7 @@ export const getProfiles = async (userIds: string[]) => {
|
|||||||
return await pg.map(
|
return await pg.map(
|
||||||
`
|
`
|
||||||
select
|
select
|
||||||
${LOVER_COLS}
|
${PROFILE_COLS}
|
||||||
from
|
from
|
||||||
profiles
|
profiles
|
||||||
join
|
join
|
||||||
@@ -67,7 +67,7 @@ export const getGenderCompatibleProfiles = async (profile: ProfileRow) => {
|
|||||||
const profiles = await pg.map(
|
const profiles = await pg.map(
|
||||||
`
|
`
|
||||||
select
|
select
|
||||||
${LOVER_COLS}
|
${PROFILE_COLS}
|
||||||
from profiles
|
from profiles
|
||||||
join
|
join
|
||||||
users on users.id = profiles.user_id
|
users on users.id = profiles.user_id
|
||||||
@@ -92,7 +92,7 @@ export const getCompatibleProfiles = async (
|
|||||||
return await pg.map(
|
return await pg.map(
|
||||||
`
|
`
|
||||||
select
|
select
|
||||||
${LOVER_COLS}
|
${PROFILE_COLS}
|
||||||
from profiles
|
from profiles
|
||||||
join
|
join
|
||||||
users on users.id = profiles.user_id
|
users on users.id = profiles.user_id
|
||||||
@@ -122,9 +122,9 @@ export const getCompatibleProfiles = async (
|
|||||||
|
|
||||||
export const getCompatibilityAnswers = async (userIds: string[]) => {
|
export const getCompatibilityAnswers = async (userIds: string[]) => {
|
||||||
const pg = createSupabaseDirectClient()
|
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)
|
where creator_id = any($1)
|
||||||
`,
|
`,
|
||||||
[userIds]
|
[userIds]
|
||||||
@@ -11,7 +11,7 @@ export {SupabaseClient} from 'common/supabase/utils'
|
|||||||
export const pgp = pgPromise({
|
export const pgp = pgPromise({
|
||||||
error(err: any, e: pgPromise.IEventContext) {
|
error(err: any, e: pgPromise.IEventContext) {
|
||||||
// Read more: https://node-postgres.com/apis/pool#error
|
// Read more: https://node-postgres.com/apis/pool#error
|
||||||
log.error('pgPromise background error', {
|
log.error(`pgPromise background error: ${err?.detail}`, {
|
||||||
error: err,
|
error: err,
|
||||||
event: e,
|
event: e,
|
||||||
})
|
})
|
||||||
|
|||||||
48
backend/shared/src/supabase/messages.ts
Normal file
48
backend/shared/src/supabase/messages.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -8,6 +8,13 @@ CREATE TABLE IF NOT EXISTS bookmarked_searches (
|
|||||||
search_name TEXT DEFAULT NULL
|
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
|
-- Row Level Security
|
||||||
ALTER TABLE bookmarked_searches ENABLE 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
|
CREATE POLICY "public read" ON bookmarked_searches
|
||||||
FOR SELECT USING (true);
|
FOR SELECT USING (true);
|
||||||
|
|
||||||
DROP POLICY IF EXISTS "self delete" ON bookmarked_searches;
|
-- DROP POLICY IF EXISTS "self delete" ON bookmarked_searches;
|
||||||
CREATE POLICY "self delete" ON bookmarked_searches
|
-- CREATE POLICY "self delete" ON bookmarked_searches
|
||||||
FOR DELETE USING (creator_id = firebase_uid());
|
-- FOR DELETE USING (creator_id = firebase_uid());
|
||||||
|
--
|
||||||
DROP POLICY IF EXISTS "self insert" ON bookmarked_searches;
|
-- DROP POLICY IF EXISTS "self insert" ON bookmarked_searches;
|
||||||
CREATE POLICY "self insert" ON bookmarked_searches
|
-- CREATE POLICY "self insert" ON bookmarked_searches
|
||||||
FOR INSERT WITH CHECK (creator_id = firebase_uid());
|
-- FOR INSERT WITH CHECK (creator_id = firebase_uid());
|
||||||
|
--
|
||||||
DROP POLICY IF EXISTS "self update" ON bookmarked_searches;
|
-- DROP POLICY IF EXISTS "self update" ON bookmarked_searches;
|
||||||
CREATE POLICY "self update" ON bookmarked_searches
|
-- CREATE POLICY "self update" ON bookmarked_searches
|
||||||
FOR UPDATE USING (creator_id = firebase_uid());
|
-- FOR UPDATE USING (creator_id = firebase_uid());
|
||||||
|
|
||||||
-- Indexes
|
-- Indexes
|
||||||
CREATE INDEX IF NOT EXISTS bookmarked_searches_creator_id_created_time_idx
|
CREATE INDEX IF NOT EXISTS bookmarked_searches_creator_id_created_time_idx
|
||||||
|
|||||||
52
backend/supabase/compatibility_answers.sql
Normal file
52
backend/supabase/compatibility_answers.sql
Normal 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);
|
||||||
45
backend/supabase/compatibility_answers_free.sql
Normal file
45
backend/supabase/compatibility_answers_free.sql
Normal 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);
|
||||||
28
backend/supabase/compatibility_prompts.sql
Normal file
28
backend/supabase/compatibility_prompts.sql
Normal 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.
|
||||||
14
backend/supabase/contact.sql
Normal file
14
backend/supabase/contact.sql
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
create table if not exists
|
||||||
|
contact (
|
||||||
|
id text default uuid_generate_v4 () not null primary key,
|
||||||
|
created_time timestamp with time zone default now(),
|
||||||
|
user_id text,
|
||||||
|
content jsonb
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Foreign Keys
|
||||||
|
alter table contact
|
||||||
|
add constraint contact_user_id_fkey foreign key (user_id) references users (id);
|
||||||
|
|
||||||
|
-- Row Level Security
|
||||||
|
alter table contact enable row level security;
|
||||||
@@ -2,15 +2,15 @@
|
|||||||
create
|
create
|
||||||
or replace function public.to_jsonb (jsonb) returns jsonb language sql immutable parallel SAFE strict as $function$ select $1 $function$;
|
or replace function public.to_jsonb (jsonb) returns jsonb language sql immutable parallel SAFE strict as $function$ select $1 $function$;
|
||||||
|
|
||||||
create
|
-- create
|
||||||
or replace function public.ts_to_millis (ts timestamp without time zone) returns bigint language sql immutable parallel SAFE as $function$
|
-- 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
|
-- select extract(epoch from ts)::bigint * 1000
|
||||||
$function$;
|
-- $function$;
|
||||||
|
--
|
||||||
create
|
-- create
|
||||||
or replace function public.ts_to_millis (ts timestamp with time zone) returns bigint language sql immutable parallel SAFE as $function$
|
-- 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
|
-- select (extract(epoch from ts) * 1000)::bigint
|
||||||
$function$;
|
-- $function$;
|
||||||
|
|
||||||
create
|
create
|
||||||
or replace function public.millis_to_ts (millis bigint) returns timestamp with time zone language sql immutable parallel SAFE as $function$
|
or replace function public.millis_to_ts (millis bigint) returns timestamp with time zone language sql immutable parallel SAFE as $function$
|
||||||
|
|||||||
@@ -1,34 +1,34 @@
|
|||||||
|
|
||||||
|
|
||||||
create
|
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
|
BEGIN
|
||||||
RETURN QUERY
|
RETURN QUERY
|
||||||
SELECT
|
SELECT
|
||||||
love_questions.*,
|
compatibility_prompts.*,
|
||||||
COUNT(love_compatibility_answers.question_id) as answer_count
|
COUNT(compatibility_answers.question_id) as answer_count
|
||||||
FROM
|
FROM
|
||||||
love_questions
|
compatibility_prompts
|
||||||
LEFT JOIN
|
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'
|
WHERE compatibility_prompts.answer_type='compatibility_multiple_choice'
|
||||||
GROUP BY
|
GROUP BY
|
||||||
love_questions.id
|
compatibility_prompts.id
|
||||||
ORDER BY
|
ORDER BY
|
||||||
answer_count DESC;
|
answer_count DESC;
|
||||||
END;
|
END;
|
||||||
$function$;
|
$function$;
|
||||||
|
|
||||||
create
|
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
|
BEGIN
|
||||||
RETURN QUERY
|
RETURN QUERY
|
||||||
SELECT
|
SELECT
|
||||||
love_answers.question_id,
|
compatibility_answers_free.question_id,
|
||||||
love_answers.created_time,
|
compatibility_answers_free.created_time,
|
||||||
love_answers.free_response,
|
compatibility_answers_free.free_response,
|
||||||
love_answers.multiple_choice,
|
compatibility_answers_free.multiple_choice,
|
||||||
love_answers.integer,
|
compatibility_answers_free.integer,
|
||||||
profiles.age,
|
profiles.age,
|
||||||
profiles.gender,
|
profiles.gender,
|
||||||
profiles.city,
|
profiles.city,
|
||||||
@@ -36,11 +36,11 @@ SELECT
|
|||||||
FROM
|
FROM
|
||||||
profiles
|
profiles
|
||||||
JOIN
|
JOIN
|
||||||
love_answers ON profiles.user_id = love_answers.creator_id
|
compatibility_answers_free ON profiles.user_id = compatibility_answers_free.creator_id
|
||||||
join
|
join
|
||||||
users on profiles.user_id = users.id
|
users on profiles.user_id = users.id
|
||||||
WHERE
|
WHERE
|
||||||
love_answers.question_id = p_question_id
|
compatibility_answers_free.question_id = p_question_id
|
||||||
order by love_answers.created_time desc;
|
order by compatibility_answers_free.created_time desc;
|
||||||
END;
|
END;
|
||||||
$function$;
|
$function$;
|
||||||
@@ -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);
|
|
||||||
@@ -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);
|
|
||||||
@@ -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);
|
|
||||||
@@ -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.
|
|
||||||
@@ -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);
|
|
||||||
@@ -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);
|
|
||||||
@@ -8,14 +8,14 @@ BEGIN;
|
|||||||
\i backend/supabase/private_users.sql
|
\i backend/supabase/private_users.sql
|
||||||
\i backend/supabase/private_user_messages.sql
|
\i backend/supabase/private_user_messages.sql
|
||||||
\i backend/supabase/private_user_seen_message_channels.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/profile_comments.sql
|
||||||
\i backend/supabase/love_compatibility_answers.sql
|
\i backend/supabase/compatibility_answers.sql
|
||||||
\i backend/supabase/love_likes.sql
|
\i backend/supabase/profile_likes.sql
|
||||||
\i backend/supabase/love_questions.sql
|
\i backend/supabase/compatibility_prompts.sql
|
||||||
\i backend/supabase/love_ships.sql
|
\i backend/supabase/profile_ships.sql
|
||||||
\i backend/supabase/love_stars.sql
|
\i backend/supabase/profile_stars.sql
|
||||||
\i backend/supabase/love_waitlist.sql
|
\i backend/supabase/user_waitlist.sql
|
||||||
\i backend/supabase/user_events.sql
|
\i backend/supabase/user_events.sql
|
||||||
\i backend/supabase/user_notifications.sql
|
\i backend/supabase/user_notifications.sql
|
||||||
\i backend/supabase/functions_others.sql
|
\i backend/supabase/functions_others.sql
|
||||||
|
|||||||
0
backend/supabase/migrations/.keep
Normal file
0
backend/supabase/migrations/.keep
Normal file
@@ -10,6 +10,12 @@ CREATE TABLE IF NOT EXISTS private_user_message_channel_members (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- Foreign Keys
|
-- 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 $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF NOT EXISTS (
|
IF NOT EXISTS (
|
||||||
@@ -30,12 +36,10 @@ END$$;
|
|||||||
ALTER TABLE private_user_message_channel_members ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE private_user_message_channel_members ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
-- Indexes
|
-- Indexes
|
||||||
DROP INDEX IF EXISTS pumcm_members_idx;
|
CREATE INDEX IF NOT EXISTS pumcm_members_idx
|
||||||
CREATE INDEX pumcm_members_idx
|
|
||||||
ON public.private_user_message_channel_members
|
ON public.private_user_message_channel_members
|
||||||
USING btree (channel_id, user_id);
|
USING btree (channel_id, user_id);
|
||||||
|
|
||||||
DROP INDEX IF EXISTS unique_user_channel;
|
CREATE UNIQUE INDEX IF NOT EXISTS unique_user_channel
|
||||||
CREATE UNIQUE INDEX unique_user_channel
|
|
||||||
ON public.private_user_message_channel_members
|
ON public.private_user_message_channel_members
|
||||||
USING btree (channel_id, user_id);
|
USING btree (channel_id, user_id);
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ CREATE TABLE IF NOT EXISTS private_user_message_channels (
|
|||||||
-- Row Level Security
|
-- Row Level Security
|
||||||
ALTER TABLE private_user_message_channels ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE private_user_message_channels ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
-- Policies
|
-- Policies
|
||||||
DROP POLICY IF EXISTS "public read" ON private_user_message_channels;
|
DROP POLICY IF EXISTS "public read" ON private_user_message_channels;
|
||||||
|
|
||||||
CREATE POLICY "public read" ON private_user_message_channels
|
CREATE POLICY "public read" ON private_user_message_channels
|
||||||
FOR ALL
|
FOR SELECT USING (true);
|
||||||
USING (true);
|
|
||||||
|
|
||||||
-- Indexes
|
-- Indexes
|
||||||
-- Removed redundant primary key index creation because PRIMARY KEY already creates a unique index on id
|
-- Removed redundant primary key index creation because PRIMARY KEY already creates a unique index on id
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
CREATE TABLE IF NOT EXISTS private_user_messages (
|
CREATE TABLE IF NOT EXISTS private_user_messages (
|
||||||
channel_id BIGINT NOT NULL,
|
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,
|
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
old_id BIGINT,
|
user_id TEXT,
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
visibility TEXT DEFAULT 'private'::TEXT NOT NULL,
|
visibility TEXT DEFAULT 'private'::TEXT NOT NULL,
|
||||||
CONSTRAINT private_user_messages_channel_id_fkey
|
CONSTRAINT private_user_messages_channel_id_fkey
|
||||||
FOREIGN KEY (channel_id)
|
FOREIGN KEY (channel_id)
|
||||||
@@ -12,11 +14,16 @@ CREATE TABLE IF NOT EXISTS private_user_messages (
|
|||||||
ON UPDATE CASCADE ON DELETE CASCADE
|
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
|
-- Row Level Security
|
||||||
ALTER TABLE private_user_messages ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE private_user_messages ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
-- Indexes
|
-- Indexes
|
||||||
DROP INDEX IF EXISTS private_user_messages_channel_id_idx;
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT 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);
|
ON public.private_user_messages USING btree (channel_id, created_time DESC);
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ CREATE TABLE IF NOT EXISTS private_user_seen_message_channels (
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- Foreign Keys
|
-- 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 $$
|
DO $$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF NOT EXISTS (
|
IF NOT EXISTS (
|
||||||
@@ -47,8 +53,6 @@ FOR SELECT
|
|||||||
);
|
);
|
||||||
|
|
||||||
-- Indexes
|
-- Indexes
|
||||||
DROP INDEX IF EXISTS user_seen_private_messages_created_time_desc_idx;
|
CREATE INDEX IF NOT EXISTS user_seen_private_messages_created_time_desc_idx
|
||||||
|
|
||||||
CREATE INDEX user_seen_private_messages_created_time_desc_idx
|
|
||||||
ON public.private_user_seen_message_channels
|
ON public.private_user_seen_message_channels
|
||||||
USING btree (user_id, channel_id, created_time DESC);
|
USING btree (user_id, channel_id, created_time DESC);
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ CREATE TABLE IF NOT EXISTS private_users (
|
|||||||
CONSTRAINT private_users_pkey PRIMARY KEY (id)
|
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
|
-- Row Level Security
|
||||||
ALTER TABLE private_users ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE private_users ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
|||||||
@@ -11,12 +11,28 @@ CREATE TABLE IF NOT EXISTS profile_comments (
|
|||||||
user_username TEXT NOT NULL
|
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
|
-- Row Level Security
|
||||||
ALTER TABLE profile_comments ENABLE 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
|
-- Policies
|
||||||
DROP POLICY IF EXISTS "public read" ON profile_comments;
|
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
|
-- Indexes
|
||||||
CREATE INDEX IF NOT EXISTS profile_comments_user_id_idx
|
CREATE INDEX IF NOT EXISTS profile_comments_user_id_idx
|
||||||
|
|||||||
28
backend/supabase/profile_likes.sql
Normal file
28
backend/supabase/profile_likes.sql
Normal 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);
|
||||||
28
backend/supabase/profile_ships.sql
Normal file
28
backend/supabase/profile_ships.sql
Normal 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);
|
||||||
31
backend/supabase/profile_stars.sql
Normal file
31
backend/supabase/profile_stars.sql
Normal 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);
|
||||||
@@ -27,8 +27,7 @@ CREATE TABLE IF NOT EXISTS profiles (
|
|||||||
height_in_inches INTEGER,
|
height_in_inches INTEGER,
|
||||||
id BIGINT GENERATED ALWAYS AS IDENTITY NOT NULL,
|
id BIGINT GENERATED ALWAYS AS IDENTITY NOT NULL,
|
||||||
is_smoker BOOLEAN,
|
is_smoker BOOLEAN,
|
||||||
is_vegetarian_or_vegan BOOLEAN,
|
diet TEXT[],
|
||||||
last_online_time TIMESTAMPTZ DEFAULT now() NOT NULL,
|
|
||||||
last_modification_time TIMESTAMPTZ DEFAULT now() NOT NULL,
|
last_modification_time TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
looking_for_matches BOOLEAN DEFAULT TRUE NOT NULL,
|
looking_for_matches BOOLEAN DEFAULT TRUE NOT NULL,
|
||||||
messaging_status TEXT DEFAULT 'open'::TEXT NOT NULL,
|
messaging_status TEXT DEFAULT 'open'::TEXT NOT NULL,
|
||||||
@@ -41,6 +40,7 @@ CREATE TABLE IF NOT EXISTS profiles (
|
|||||||
pref_age_min INTEGER NULL,
|
pref_age_min INTEGER NULL,
|
||||||
pref_gender TEXT[] NOT NULL,
|
pref_gender TEXT[] NOT NULL,
|
||||||
pref_relation_styles TEXT[] NOT NULL,
|
pref_relation_styles TEXT[] NOT NULL,
|
||||||
|
pref_romantic_styles TEXT[],
|
||||||
referred_by_username TEXT,
|
referred_by_username TEXT,
|
||||||
region_code TEXT,
|
region_code TEXT,
|
||||||
religious_belief_strength INTEGER,
|
religious_belief_strength INTEGER,
|
||||||
@@ -54,15 +54,20 @@ CREATE TABLE IF NOT EXISTS profiles (
|
|||||||
CONSTRAINT profiles_pkey PRIMARY KEY (id)
|
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
|
-- Row Level Security
|
||||||
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
-- Policies
|
-- Policies
|
||||||
DROP POLICY IF EXISTS "public read" ON profiles;
|
DROP POLICY IF EXISTS "public read" ON profiles;
|
||||||
|
|
||||||
CREATE POLICY "public read" ON profiles
|
CREATE POLICY "public read" ON profiles
|
||||||
FOR SELECT
|
FOR SELECT USING (true);
|
||||||
USING (true);
|
|
||||||
|
|
||||||
DROP POLICY IF EXISTS "self update" ON profiles;
|
DROP POLICY IF EXISTS "self update" ON profiles;
|
||||||
|
|
||||||
@@ -71,11 +76,9 @@ FOR UPDATE
|
|||||||
WITH CHECK ((user_id = firebase_uid()));
|
WITH CHECK ((user_id = firebase_uid()));
|
||||||
|
|
||||||
-- Indexes
|
-- Indexes
|
||||||
DROP INDEX IF EXISTS profiles_user_id_idx;
|
CREATE INDEX IF NOT EXISTS profiles_user_id_idx ON public.profiles USING btree (user_id);
|
||||||
CREATE INDEX profiles_user_id_idx ON public.profiles USING btree (user_id);
|
|
||||||
|
|
||||||
DROP INDEX IF EXISTS unique_user_id;
|
CREATE UNIQUE INDEX IF NOT EXISTS unique_user_id ON public.profiles USING btree (user_id);
|
||||||
CREATE UNIQUE INDEX unique_user_id ON public.profiles USING btree (user_id);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_profiles_last_mod_24h
|
CREATE INDEX IF NOT EXISTS idx_profiles_last_mod_24h
|
||||||
ON public.profiles USING btree (last_modification_time);
|
ON public.profiles USING btree (last_modification_time);
|
||||||
@@ -83,23 +86,23 @@ CREATE INDEX IF NOT EXISTS idx_profiles_last_mod_24h
|
|||||||
CREATE INDEX IF NOT EXISTS idx_profiles_bio_length
|
CREATE INDEX IF NOT EXISTS idx_profiles_bio_length
|
||||||
ON profiles (bio_length);
|
ON profiles (bio_length);
|
||||||
|
|
||||||
|
-- Fastest general-purpose index
|
||||||
|
CREATE INDEX IF NOT EXISTS profiles_lat_lon_idx ON profiles (city_latitude, city_longitude);
|
||||||
|
|
||||||
|
-- Optional additional index for large tables / clustered inserts
|
||||||
|
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
|
-- Functions and Triggers
|
||||||
CREATE
|
CREATE
|
||||||
OR REPLACE FUNCTION update_last_modification_time()
|
OR REPLACE FUNCTION update_last_modification_time()
|
||||||
RETURNS TRIGGER AS $$
|
RETURNS TRIGGER AS $$
|
||||||
BEGIN
|
BEGIN
|
||||||
IF NEW.last_online_time IS DISTINCT FROM OLD.last_online_time AND row(NEW.*) = row(OLD.*) THEN
|
NEW.last_modification_time = now();
|
||||||
-- Only last_online_time changed, do nothing
|
RETURN NEW;
|
||||||
RETURN NEW;
|
|
||||||
END IF;
|
|
||||||
|
|
||||||
-- Some other column changed
|
|
||||||
NEW.last_modification_time = now();
|
|
||||||
RETURN NEW;
|
|
||||||
END;
|
END;
|
||||||
$$
|
$$ LANGUAGE plpgsql;
|
||||||
LANGUAGE plpgsql;
|
|
||||||
|
|
||||||
CREATE TRIGGER trigger_update_last_mod_time
|
CREATE TRIGGER trigger_update_last_mod_time
|
||||||
BEFORE UPDATE
|
BEFORE UPDATE
|
||||||
@@ -141,5 +144,3 @@ $$ LANGUAGE plpgsql;
|
|||||||
CREATE TRIGGER trg_update_bio_text
|
CREATE TRIGGER trg_update_bio_text
|
||||||
BEFORE INSERT OR UPDATE OF bio ON profiles
|
BEFORE INSERT OR UPDATE OF bio ON profiles
|
||||||
FOR EACH ROW EXECUTE FUNCTION update_bio_text();
|
FOR EACH ROW EXECUTE FUNCTION update_bio_text();
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
17
backend/supabase/push_subscriptions.sql
Normal file
17
backend/supabase/push_subscriptions.sql
Normal 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);
|
||||||
16
backend/supabase/user_activity.sql
Normal file
16
backend/supabase/user_activity.sql
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TABLE user_activity
|
||||||
|
(
|
||||||
|
user_id TEXT PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
last_online_time TIMESTAMPTZ NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Row Level Security
|
||||||
|
ALTER TABLE user_activity ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
DROP POLICY IF EXISTS "public read" ON user_activity;
|
||||||
|
CREATE POLICY "public read" ON user_activity
|
||||||
|
FOR SELECT USING (true);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_activity_last_online_time
|
||||||
|
ON user_activity (last_online_time DESC);
|
||||||
@@ -5,6 +5,13 @@ CREATE TABLE IF NOT EXISTS user_notifications (
|
|||||||
CONSTRAINT user_notifications_pkey PRIMARY KEY (notification_id, user_id)
|
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
|
-- Row Level Security
|
||||||
ALTER TABLE user_notifications ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE user_notifications ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
|
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
email TEXT NOT NULL
|
email TEXT NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Row Level Security
|
-- Row Level Security
|
||||||
ALTER TABLE love_waitlist ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE user_waitlist ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
-- Policies
|
-- Policies
|
||||||
DROP POLICY IF EXISTS "anon insert" ON love_waitlist;
|
DROP POLICY IF EXISTS "anon insert" ON user_waitlist;
|
||||||
CREATE POLICY "anon insert" ON love_waitlist
|
CREATE POLICY "anon insert" ON user_waitlist
|
||||||
FOR INSERT WITH CHECK (true);
|
FOR INSERT WITH CHECK (true);
|
||||||
|
|
||||||
-- Indexes
|
-- Indexes
|
||||||
@@ -18,20 +18,16 @@ ALTER TABLE users ENABLE ROW LEVEL SECURITY;
|
|||||||
|
|
||||||
-- Policies
|
-- Policies
|
||||||
DROP POLICY IF EXISTS "public read" ON users;
|
DROP POLICY IF EXISTS "public read" ON users;
|
||||||
|
|
||||||
CREATE POLICY "public read" ON users
|
CREATE POLICY "public read" ON users
|
||||||
FOR SELECT
|
FOR SELECT
|
||||||
USING (true);
|
USING (true);
|
||||||
|
|
||||||
-- Indexes
|
-- Indexes
|
||||||
DROP INDEX IF EXISTS user_username_idx;
|
CREATE INDEX IF NOT EXISTS user_username_idx ON public.users USING btree (username);
|
||||||
CREATE INDEX user_username_idx ON public.users USING btree (username);
|
|
||||||
|
|
||||||
DROP INDEX IF EXISTS users_created_time;
|
CREATE INDEX IF NOT EXISTS users_created_time ON public.users USING btree (created_time DESC);
|
||||||
CREATE INDEX users_created_time ON public.users USING btree (created_time DESC);
|
|
||||||
|
|
||||||
DROP INDEX IF EXISTS users_name_idx;
|
CREATE INDEX IF NOT EXISTS users_name_idx ON public.users USING btree (name);
|
||||||
CREATE INDEX users_name_idx ON public.users USING btree (name);
|
|
||||||
|
|
||||||
-- Remove these if you trust PRIMARY KEY auto-index:
|
-- Remove these if you trust PRIMARY KEY auto-index:
|
||||||
-- DROP INDEX IF EXISTS users_pkey;
|
-- DROP INDEX IF EXISTS users_pkey;
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
89
backend/supabase/vote_results.sql
Normal file
89
backend/supabase/vote_results.sql
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS vote_results (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
vote_id BIGINT NOT NULL,
|
||||||
|
choice smallint NOT NULL CHECK (choice IN (-1, 0, 1)),
|
||||||
|
priority smallint NOT NULL CHECK (priority IN (0, 1, 2, 3)),
|
||||||
|
UNIQUE (user_id, vote_id) -- ensures one vote per user
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Foreign Keys
|
||||||
|
alter table vote_results
|
||||||
|
add constraint vote_results_user_id_fkey foreign key (user_id) references users (id);
|
||||||
|
|
||||||
|
alter table vote_results
|
||||||
|
add constraint vote_results_vote_id_fkey foreign key (vote_id) references votes (id);
|
||||||
|
|
||||||
|
-- Row Level Security
|
||||||
|
ALTER TABLE vote_results ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Policies
|
||||||
|
DROP POLICY IF EXISTS "public read" ON vote_results;
|
||||||
|
CREATE POLICY "public read" ON vote_results
|
||||||
|
FOR SELECT USING (true);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS user_id_idx ON vote_results (user_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS vote_id_idx ON vote_results (vote_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_vote_results_vote_choice ON vote_results (vote_id, choice);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_vote_results_vote_choice_priority ON vote_results (vote_id, choice, priority);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_votes_created_time ON votes (created_time DESC);
|
||||||
|
|
||||||
|
|
||||||
|
drop function if exists get_votes_with_results;
|
||||||
|
create or replace function get_votes_with_results(order_by text default 'recent')
|
||||||
|
returns table (
|
||||||
|
id BIGINT,
|
||||||
|
title text,
|
||||||
|
description jsonb,
|
||||||
|
created_time timestamptz,
|
||||||
|
creator_id TEXT,
|
||||||
|
is_anonymous boolean,
|
||||||
|
votes_for int,
|
||||||
|
votes_against int,
|
||||||
|
votes_abstain int,
|
||||||
|
priority int
|
||||||
|
)
|
||||||
|
as $$
|
||||||
|
with results as (
|
||||||
|
SELECT
|
||||||
|
v.id,
|
||||||
|
v.title,
|
||||||
|
v.description,
|
||||||
|
v.created_time,
|
||||||
|
v.creator_id,
|
||||||
|
v.is_anonymous,
|
||||||
|
COALESCE(SUM(CASE WHEN r.choice = 1 THEN 1 ELSE 0 END), 0) AS votes_for,
|
||||||
|
COALESCE(SUM(CASE WHEN r.choice = -1 THEN 1 ELSE 0 END), 0) AS votes_against,
|
||||||
|
COALESCE(SUM(CASE WHEN r.choice = 0 THEN 1 ELSE 0 END), 0) AS votes_abstain,
|
||||||
|
COALESCE(SUM(r.priority), 0)::float / GREATEST(COALESCE(SUM(CASE WHEN r.choice = 1 THEN 1 ELSE 0 END), 1), 1) * 100 / 3 AS priority
|
||||||
|
FROM votes v
|
||||||
|
LEFT JOIN vote_results r ON v.id = r.vote_id
|
||||||
|
GROUP BY v.id
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
created_time,
|
||||||
|
creator_id,
|
||||||
|
is_anonymous,
|
||||||
|
votes_for,
|
||||||
|
votes_against,
|
||||||
|
votes_abstain,
|
||||||
|
priority
|
||||||
|
FROM results
|
||||||
|
ORDER BY
|
||||||
|
CASE WHEN order_by = 'recent' THEN created_time END DESC,
|
||||||
|
CASE WHEN order_by = 'mostVoted' THEN (votes_for + votes_against + votes_abstain) END DESC,
|
||||||
|
CASE WHEN order_by = 'mostVoted' THEN created_time END DESC,
|
||||||
|
CASE WHEN order_by = 'priority' THEN priority END DESC,
|
||||||
|
CASE WHEN order_by = 'priority' THEN created_time END DESC;
|
||||||
|
$$ language sql stable;
|
||||||
|
|
||||||
|
|
||||||
25
backend/supabase/votes.sql
Normal file
25
backend/supabase/votes.sql
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS votes (
|
||||||
|
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
|
||||||
|
created_time TIMESTAMPTZ DEFAULT now() NOT NULL,
|
||||||
|
creator_id TEXT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
is_anonymous BOOLEAN NOT NULL,
|
||||||
|
description JSONB
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Foreign Keys
|
||||||
|
alter table votes
|
||||||
|
add constraint votes_creator_id_fkey foreign key (creator_id) references users (id);
|
||||||
|
|
||||||
|
-- Row Level Security
|
||||||
|
ALTER TABLE votes ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
-- Policies
|
||||||
|
DROP POLICY IF EXISTS "public read" ON votes;
|
||||||
|
CREATE POLICY "public read" ON votes
|
||||||
|
FOR SELECT USING (true);
|
||||||
|
|
||||||
|
-- Indexes
|
||||||
|
CREATE INDEX IF NOT EXISTS creator_id_idx ON votes (creator_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_votes_created_time ON votes (created_time DESC);
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
"dayjs": "1.11.4",
|
"dayjs": "1.11.4",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"string-similarity": "4.0.4",
|
"string-similarity": "4.0.4",
|
||||||
"zod": "3.21.4"
|
"zod": "3.22.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/jest": "29.2.4",
|
"@types/jest": "29.2.4",
|
||||||
|
|||||||
@@ -1,22 +1,17 @@
|
|||||||
import {
|
import {arraybeSchema, baseProfilesSchema, combinedProfileSchema, contentSchema, zBoolean,} from 'common/api/zod-types'
|
||||||
contentSchema,
|
import {PrivateChatMessage} from 'common/chat-message'
|
||||||
combinedLoveUsersSchema,
|
import {CompatibilityScore} from 'common/profiles/compatibility-score'
|
||||||
baseProfilesSchema,
|
import {MAX_COMPATIBILITY_QUESTION_LENGTH} from 'common/profiles/constants'
|
||||||
arraybeSchema,
|
import {Profile, ProfileRow} from 'common/profiles/profile'
|
||||||
} from 'common/api/zod-types'
|
import {Row} from 'common/supabase/utils'
|
||||||
import { PrivateChatMessage } from 'common/chat-message'
|
import {PrivateUser, User} from 'common/user'
|
||||||
import { CompatibilityScore } from 'common/love/compatibility-score'
|
import {z} from 'zod'
|
||||||
import { MAX_COMPATIBILITY_QUESTION_LENGTH } from 'common/love/constants'
|
import {LikeData, ShipData} from './profile-types'
|
||||||
import { Profile, ProfileRow } from 'common/love/profile'
|
import {FullUser} from './user-types'
|
||||||
import { Row } from 'common/supabase/utils'
|
import {PrivateMessageChannel} from 'common/supabase/private-messages'
|
||||||
import { PrivateUser, User } from 'common/user'
|
import {Notification} from 'common/notifications'
|
||||||
import { z } from 'zod'
|
import {arrify} from 'common/util/array'
|
||||||
import { LikeData, ShipData } from './love-types'
|
import {notification_preference} from 'common/user-notification-preferences'
|
||||||
import { DisplayUser, FullUser } from './user-types'
|
|
||||||
import { PrivateMessageChannel } from 'common/supabase/private-messages'
|
|
||||||
import { Notification } from 'common/notifications'
|
|
||||||
import { arrify } from 'common/util/array'
|
|
||||||
import { notification_preference } from 'common/user-notification-preferences'
|
|
||||||
|
|
||||||
// mqp: very unscientific, just balancing our willingness to accept load
|
// mqp: very unscientific, just balancing our willingness to accept load
|
||||||
// with user willingness to put up with stale data
|
// with user willingness to put up with stale data
|
||||||
@@ -28,12 +23,18 @@ type APIGenericSchema = {
|
|||||||
method: 'GET' | 'POST' | 'PUT'
|
method: 'GET' | 'POST' | 'PUT'
|
||||||
// whether the endpoint requires authentication
|
// whether the endpoint requires authentication
|
||||||
authed: boolean
|
authed: boolean
|
||||||
|
// whether the endpoint requires authentication
|
||||||
|
rateLimited?: boolean
|
||||||
// zod schema for the request body (or for params for GET requests)
|
// zod schema for the request body (or for params for GET requests)
|
||||||
props: z.ZodType
|
props: z.ZodType
|
||||||
// note this has to be JSON serializable
|
// note this has to be JSON serializable
|
||||||
returns?: Record<string, any>
|
returns?: Record<string, any>
|
||||||
// Cache-Control header. like, 'max-age=60'
|
// Cache-Control header. like, 'max-age=60'
|
||||||
cache?: string
|
cache?: string
|
||||||
|
// Description of the endpoint
|
||||||
|
summary?: string
|
||||||
|
// Tag for grouping endpoints in documentation
|
||||||
|
tag?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
let _apiTypeCheck: { [x: string]: APIGenericSchema }
|
let _apiTypeCheck: { [x: string]: APIGenericSchema }
|
||||||
@@ -42,44 +43,99 @@ export const API = (_apiTypeCheck = {
|
|||||||
health: {
|
health: {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
authed: false,
|
authed: false,
|
||||||
|
rateLimited: false,
|
||||||
props: z.object({}),
|
props: z.object({}),
|
||||||
returns: {} as { message: 'Server is working.'; uid?: string },
|
returns: {} as { message: 'Server is working.'; uid?: string },
|
||||||
|
summary: 'Check whether the API server is running',
|
||||||
|
tag: 'General',
|
||||||
},
|
},
|
||||||
'get-supabase-token': {
|
'get-supabase-token': {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
authed: true,
|
authed: true,
|
||||||
|
rateLimited: false,
|
||||||
props: z.object({}),
|
props: z.object({}),
|
||||||
returns: {} as { jwt: string },
|
returns: {} as { jwt: string },
|
||||||
|
summary: 'Return a Supabase JWT for authenticated clients',
|
||||||
|
tag: 'Tokens',
|
||||||
},
|
},
|
||||||
'mark-all-notifs-read': {
|
'mark-all-notifs-read': {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
authed: true,
|
authed: true,
|
||||||
|
rateLimited: false,
|
||||||
props: z.object({}),
|
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': {
|
'user/by-id/:id/block': {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
authed: true,
|
authed: true,
|
||||||
props: z.object({ id: z.string() }).strict(),
|
rateLimited: false,
|
||||||
|
props: z.object({id: z.string()}).strict(),
|
||||||
|
summary: 'Block a user by their ID',
|
||||||
|
tag: 'Users',
|
||||||
},
|
},
|
||||||
'user/by-id/:id/unblock': {
|
'user/by-id/:id/unblock': {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
authed: true,
|
authed: true,
|
||||||
props: z.object({ id: z.string() }).strict(),
|
rateLimited: false,
|
||||||
|
props: z.object({id: z.string()}).strict(),
|
||||||
|
summary: 'Unblock a user by their ID',
|
||||||
|
tag: 'Users',
|
||||||
},
|
},
|
||||||
'ban-user': {
|
'ban-user': {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
authed: true,
|
authed: true,
|
||||||
|
rateLimited: false,
|
||||||
props: z
|
props: z
|
||||||
.object({
|
.object({
|
||||||
userId: z.string(),
|
userId: z.string(),
|
||||||
unban: z.boolean().optional(),
|
unban: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
.strict(),
|
.strict(),
|
||||||
|
summary: 'Ban or unban a user',
|
||||||
|
tag: 'Admin',
|
||||||
},
|
},
|
||||||
'create-user': {
|
'create-user': {
|
||||||
// TODO rest
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
authed: true,
|
authed: true,
|
||||||
|
rateLimited: true,
|
||||||
returns: {} as { user: User; privateUser: PrivateUser },
|
returns: {} as { user: User; privateUser: PrivateUser },
|
||||||
props: z
|
props: z
|
||||||
.object({
|
.object({
|
||||||
@@ -87,16 +143,22 @@ export const API = (_apiTypeCheck = {
|
|||||||
adminToken: z.string().optional(),
|
adminToken: z.string().optional(),
|
||||||
})
|
})
|
||||||
.strict(),
|
.strict(),
|
||||||
|
summary: 'Create a new user (admin or onboarding flow)',
|
||||||
|
tag: 'Users',
|
||||||
},
|
},
|
||||||
'create-profile': {
|
'create-profile': {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
authed: true,
|
authed: true,
|
||||||
|
rateLimited: true,
|
||||||
returns: {} as Row<'profiles'>,
|
returns: {} as Row<'profiles'>,
|
||||||
props: baseProfilesSchema,
|
props: baseProfilesSchema,
|
||||||
|
summary: 'Create a new profile for the authenticated user',
|
||||||
|
tag: 'Profiles',
|
||||||
},
|
},
|
||||||
report: {
|
report: {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
authed: true,
|
authed: true,
|
||||||
|
rateLimited: true,
|
||||||
props: z
|
props: z
|
||||||
.object({
|
.object({
|
||||||
contentOwnerId: z.string(),
|
contentOwnerId: z.string(),
|
||||||
@@ -108,17 +170,23 @@ export const API = (_apiTypeCheck = {
|
|||||||
})
|
})
|
||||||
.strict(),
|
.strict(),
|
||||||
returns: {} as any,
|
returns: {} as any,
|
||||||
|
summary: 'Submit a report for content or a user',
|
||||||
|
tag: 'Moderation',
|
||||||
},
|
},
|
||||||
me: {
|
me: {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
authed: true,
|
authed: true,
|
||||||
|
rateLimited: false,
|
||||||
cache: DEFAULT_CACHE_STRATEGY,
|
cache: DEFAULT_CACHE_STRATEGY,
|
||||||
props: z.object({}),
|
props: z.object({}),
|
||||||
returns: {} as FullUser,
|
returns: {} as FullUser,
|
||||||
|
summary: 'Get the authenticated user full data',
|
||||||
|
tag: 'Users',
|
||||||
},
|
},
|
||||||
'me/update': {
|
'me/update': {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
authed: true,
|
authed: true,
|
||||||
|
rateLimited: true,
|
||||||
props: z.object({
|
props: z.object({
|
||||||
name: z.string().trim().min(1).optional(),
|
name: z.string().trim().min(1).optional(),
|
||||||
username: z.string().trim().min(1).optional(),
|
username: z.string().trim().min(1).optional(),
|
||||||
@@ -142,80 +210,70 @@ export const API = (_apiTypeCheck = {
|
|||||||
discordHandle: z.string().optional(),
|
discordHandle: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
returns: {} as FullUser,
|
returns: {} as FullUser,
|
||||||
|
summary: 'Update authenticated user profile and settings',
|
||||||
|
tag: 'Users',
|
||||||
},
|
},
|
||||||
'update-profile': {
|
'update-profile': {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
authed: true,
|
authed: true,
|
||||||
props: combinedLoveUsersSchema.partial(),
|
rateLimited: true,
|
||||||
|
props: combinedProfileSchema.partial(),
|
||||||
returns: {} as ProfileRow,
|
returns: {} as ProfileRow,
|
||||||
|
summary: 'Update profile fields for the authenticated user',
|
||||||
|
tag: 'Profiles',
|
||||||
},
|
},
|
||||||
'update-notif-settings': {
|
'update-notif-settings': {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
authed: true,
|
authed: true,
|
||||||
|
rateLimited: false,
|
||||||
props: z.object({
|
props: z.object({
|
||||||
type: z.string() as z.ZodType<notification_preference>,
|
type: z.string() as z.ZodType<notification_preference>,
|
||||||
medium: z.enum(['email', 'browser', 'mobile']),
|
medium: z.enum(['email', 'browser', 'mobile']),
|
||||||
enabled: z.boolean(),
|
enabled: z.boolean(),
|
||||||
}),
|
}),
|
||||||
|
summary: 'Update a notification preference for the user',
|
||||||
|
tag: 'Notifications',
|
||||||
},
|
},
|
||||||
'me/delete': {
|
'me/delete': {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
authed: true,
|
authed: true,
|
||||||
|
rateLimited: true,
|
||||||
props: z.object({
|
props: z.object({
|
||||||
username: z.string(), // just so you're sure
|
username: z.string(), // just so you're sure
|
||||||
}),
|
}),
|
||||||
|
summary: 'Delete the authenticated user account',
|
||||||
|
tag: 'Users',
|
||||||
},
|
},
|
||||||
'me/private': {
|
'me/private': {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
authed: true,
|
authed: true,
|
||||||
|
rateLimited: false,
|
||||||
props: z.object({}),
|
props: z.object({}),
|
||||||
returns: {} as PrivateUser,
|
returns: {} as PrivateUser,
|
||||||
},
|
summary: 'Get private user data for the authenticated user',
|
||||||
'user/:username': {
|
tag: 'Users',
|
||||||
method: 'GET',
|
|
||||||
authed: false,
|
|
||||||
cache: DEFAULT_CACHE_STRATEGY,
|
|
||||||
returns: {} as FullUser,
|
|
||||||
props: z.object({ username: z.string() }).strict(),
|
|
||||||
},
|
|
||||||
'user/:username/lite': {
|
|
||||||
method: 'GET',
|
|
||||||
authed: false,
|
|
||||||
cache: DEFAULT_CACHE_STRATEGY,
|
|
||||||
returns: {} as DisplayUser,
|
|
||||||
props: z.object({ username: z.string() }).strict(),
|
|
||||||
},
|
|
||||||
'user/by-id/:id': {
|
|
||||||
method: 'GET',
|
|
||||||
authed: false,
|
|
||||||
cache: DEFAULT_CACHE_STRATEGY,
|
|
||||||
returns: {} as FullUser,
|
|
||||||
props: z.object({ id: z.string() }).strict(),
|
|
||||||
},
|
|
||||||
'user/by-id/:id/lite': {
|
|
||||||
method: 'GET',
|
|
||||||
authed: false,
|
|
||||||
cache: DEFAULT_CACHE_STRATEGY,
|
|
||||||
returns: {} as DisplayUser,
|
|
||||||
props: z.object({ id: z.string() }).strict(),
|
|
||||||
},
|
},
|
||||||
'search-users': {
|
'search-users': {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
authed: false,
|
authed: true,
|
||||||
|
rateLimited: true,
|
||||||
cache: DEFAULT_CACHE_STRATEGY,
|
cache: DEFAULT_CACHE_STRATEGY,
|
||||||
returns: [] as FullUser[],
|
returns: [] as FullUser[],
|
||||||
props: z
|
props: z
|
||||||
.object({
|
.object({
|
||||||
term: z.string(),
|
term: z.string(),
|
||||||
limit: z.coerce.number().gte(0).lte(1000).default(500),
|
limit: z.coerce.number().gte(0).lte(20).default(500),
|
||||||
page: z.coerce.number().gte(0).default(0),
|
page: z.coerce.number().gte(0).default(0),
|
||||||
})
|
})
|
||||||
.strict(),
|
.strict(),
|
||||||
|
summary: 'Search users by term with pagination',
|
||||||
|
tag: 'Users',
|
||||||
},
|
},
|
||||||
'compatible-profiles': {
|
'compatible-profiles': {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
authed: false,
|
authed: true,
|
||||||
props: z.object({ userId: z.string() }),
|
rateLimited: true,
|
||||||
|
props: z.object({userId: z.string()}),
|
||||||
returns: {} as {
|
returns: {} as {
|
||||||
profile: Profile
|
profile: Profile
|
||||||
compatibleProfiles: Profile[]
|
compatibleProfiles: Profile[]
|
||||||
@@ -223,32 +281,41 @@ export const API = (_apiTypeCheck = {
|
|||||||
[userId: string]: CompatibilityScore
|
[userId: string]: CompatibilityScore
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
summary: 'Find profiles compatible with a given user',
|
||||||
|
tag: 'Profiles',
|
||||||
},
|
},
|
||||||
'remove-pinned-photo': {
|
'remove-pinned-photo': {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
authed: true,
|
authed: true,
|
||||||
returns: { success: true },
|
rateLimited: true,
|
||||||
|
returns: {success: true},
|
||||||
props: z
|
props: z
|
||||||
.object({
|
.object({
|
||||||
userId: z.string(),
|
userId: z.string(),
|
||||||
})
|
})
|
||||||
.strict(),
|
.strict(),
|
||||||
|
summary: 'Remove the pinned photo from a profile',
|
||||||
|
tag: 'Profiles',
|
||||||
},
|
},
|
||||||
'get-compatibility-questions': {
|
'get-compatibility-questions': {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
authed: false,
|
authed: true,
|
||||||
|
rateLimited: false,
|
||||||
props: z.object({}),
|
props: z.object({}),
|
||||||
returns: {} as {
|
returns: {} as {
|
||||||
status: 'success'
|
status: 'success'
|
||||||
questions: (Row<'love_questions'> & {
|
questions: (Row<'compatibility_prompts'> & {
|
||||||
answer_count: number
|
answer_count: number
|
||||||
score: number
|
score: number
|
||||||
})[]
|
})[]
|
||||||
},
|
},
|
||||||
|
summary: 'Retrieve compatibility questions and stats',
|
||||||
|
tag: 'Compatibility',
|
||||||
},
|
},
|
||||||
'like-profile': {
|
'like-profile': {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
authed: true,
|
authed: true,
|
||||||
|
rateLimited: true,
|
||||||
props: z.object({
|
props: z.object({
|
||||||
targetUserId: z.string(),
|
targetUserId: z.string(),
|
||||||
remove: z.boolean().optional(),
|
remove: z.boolean().optional(),
|
||||||
@@ -256,10 +323,13 @@ export const API = (_apiTypeCheck = {
|
|||||||
returns: {} as {
|
returns: {} as {
|
||||||
status: 'success'
|
status: 'success'
|
||||||
},
|
},
|
||||||
|
summary: 'Like or unlike a profile',
|
||||||
|
tag: 'Profiles',
|
||||||
},
|
},
|
||||||
'ship-profiles': {
|
'ship-profiles': {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
authed: true,
|
authed: true,
|
||||||
|
rateLimited: true,
|
||||||
props: z.object({
|
props: z.object({
|
||||||
targetUserId1: z.string(),
|
targetUserId1: z.string(),
|
||||||
targetUserId2: z.string(),
|
targetUserId2: z.string(),
|
||||||
@@ -268,10 +338,13 @@ export const API = (_apiTypeCheck = {
|
|||||||
returns: {} as {
|
returns: {} as {
|
||||||
status: 'success'
|
status: 'success'
|
||||||
},
|
},
|
||||||
|
summary: 'Create or remove a ship between two profiles',
|
||||||
|
tag: 'Profiles',
|
||||||
},
|
},
|
||||||
'get-likes-and-ships': {
|
'get-likes-and-ships': {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
authed: false,
|
authed: true,
|
||||||
|
rateLimited: true,
|
||||||
props: z
|
props: z
|
||||||
.object({
|
.object({
|
||||||
userId: z.string(),
|
userId: z.string(),
|
||||||
@@ -283,19 +356,25 @@ export const API = (_apiTypeCheck = {
|
|||||||
likesGiven: LikeData[]
|
likesGiven: LikeData[]
|
||||||
ships: ShipData[]
|
ships: ShipData[]
|
||||||
},
|
},
|
||||||
|
summary: 'Fetch likes and ships for a user',
|
||||||
|
tag: 'Profiles',
|
||||||
},
|
},
|
||||||
'has-free-like': {
|
'has-free-like': {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
authed: true,
|
authed: true,
|
||||||
|
rateLimited: true,
|
||||||
props: z.object({}).strict(),
|
props: z.object({}).strict(),
|
||||||
returns: {} as {
|
returns: {} as {
|
||||||
status: 'success'
|
status: 'success'
|
||||||
hasFreeLike: boolean
|
hasFreeLike: boolean
|
||||||
},
|
},
|
||||||
|
summary: 'Check whether the user has a free like available',
|
||||||
|
tag: 'Profiles',
|
||||||
},
|
},
|
||||||
'star-profile': {
|
'star-profile': {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
authed: true,
|
authed: true,
|
||||||
|
rateLimited: true,
|
||||||
props: z.object({
|
props: z.object({
|
||||||
targetUserId: z.string(),
|
targetUserId: z.string(),
|
||||||
remove: z.boolean().optional(),
|
remove: z.boolean().optional(),
|
||||||
@@ -303,26 +382,38 @@ export const API = (_apiTypeCheck = {
|
|||||||
returns: {} as {
|
returns: {} as {
|
||||||
status: 'success'
|
status: 'success'
|
||||||
},
|
},
|
||||||
|
summary: 'Star or unstar a profile',
|
||||||
|
tag: 'Profiles',
|
||||||
},
|
},
|
||||||
'get-profiles': {
|
'get-profiles': {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
authed: false,
|
authed: true,
|
||||||
|
rateLimited: true,
|
||||||
props: z
|
props: z
|
||||||
.object({
|
.object({
|
||||||
limit: z.coerce.number().optional().default(20),
|
limit: z.coerce.number().gt(0).lte(20).optional().default(20),
|
||||||
after: z.string().optional(),
|
after: z.string().optional(),
|
||||||
// Search and filter parameters
|
// Search and filter parameters
|
||||||
name: z.string().optional(),
|
name: z.string().optional(),
|
||||||
genders: arraybeSchema.optional(),
|
genders: arraybeSchema.optional(),
|
||||||
|
education_levels: arraybeSchema.optional(),
|
||||||
pref_gender: arraybeSchema.optional(),
|
pref_gender: arraybeSchema.optional(),
|
||||||
pref_age_min: z.coerce.number().optional(),
|
pref_age_min: z.coerce.number().optional(),
|
||||||
pref_age_max: 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_relation_styles: arraybeSchema.optional(),
|
||||||
|
pref_romantic_styles: arraybeSchema.optional(),
|
||||||
|
diet: arraybeSchema.optional(),
|
||||||
|
political_beliefs: arraybeSchema.optional(),
|
||||||
wants_kids_strength: z.coerce.number().optional(),
|
wants_kids_strength: z.coerce.number().optional(),
|
||||||
has_kids: z.coerce.number().optional(),
|
has_kids: z.coerce.number().optional(),
|
||||||
is_smoker: z.coerce.boolean().optional(),
|
is_smoker: zBoolean.optional().optional(),
|
||||||
shortBio: z.coerce.boolean().optional(),
|
shortBio: zBoolean.optional().optional(),
|
||||||
geodbCityIds: arraybeSchema.optional(),
|
geodbCityIds: arraybeSchema.optional(),
|
||||||
|
lat: z.coerce.number().optional(),
|
||||||
|
lon: z.coerce.number().optional(),
|
||||||
|
radius: z.coerce.number().optional(),
|
||||||
compatibleWithUserId: z.string().optional(),
|
compatibleWithUserId: z.string().optional(),
|
||||||
orderBy: z
|
orderBy: z
|
||||||
.enum(['last_online_time', 'created_time', 'compatibility_score'])
|
.enum(['last_online_time', 'created_time', 'compatibility_score'])
|
||||||
@@ -334,38 +425,38 @@ export const API = (_apiTypeCheck = {
|
|||||||
status: 'success' | 'fail'
|
status: 'success' | 'fail'
|
||||||
profiles: Profile[]
|
profiles: Profile[]
|
||||||
},
|
},
|
||||||
},
|
summary: 'List profiles with filters, pagination and ordering',
|
||||||
'get-profile-answers': {
|
tag: 'Profiles',
|
||||||
method: 'GET',
|
|
||||||
authed: false,
|
|
||||||
props: z.object({ userId: z.string() }).strict(),
|
|
||||||
returns: {} as {
|
|
||||||
status: 'success'
|
|
||||||
answers: Row<'love_compatibility_answers'>[]
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
'create-comment': {
|
'create-comment': {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
authed: true,
|
authed: true,
|
||||||
|
rateLimited: true,
|
||||||
props: z.object({
|
props: z.object({
|
||||||
userId: z.string(),
|
userId: z.string(),
|
||||||
content: contentSchema,
|
content: contentSchema,
|
||||||
replyToCommentId: z.string().optional(),
|
replyToCommentId: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
returns: {} as any,
|
returns: {} as any,
|
||||||
|
summary: 'Create a comment or reply',
|
||||||
|
tag: 'Profiles',
|
||||||
},
|
},
|
||||||
'hide-comment': {
|
'hide-comment': {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
authed: true,
|
authed: true,
|
||||||
|
rateLimited: true,
|
||||||
props: z.object({
|
props: z.object({
|
||||||
commentId: z.string(),
|
commentId: z.string(),
|
||||||
hide: z.boolean(),
|
hide: z.boolean(),
|
||||||
}),
|
}),
|
||||||
returns: {} as any,
|
returns: {} as any,
|
||||||
|
summary: 'Hide or unhide a comment',
|
||||||
|
tag: 'Profiles',
|
||||||
},
|
},
|
||||||
'get-channel-memberships': {
|
'get-channel-memberships': {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
authed: true,
|
authed: true,
|
||||||
|
rateLimited: false,
|
||||||
props: z.object({
|
props: z.object({
|
||||||
channelId: z.coerce.number().optional(),
|
channelId: z.coerce.number().optional(),
|
||||||
createdTime: z.string().optional(),
|
createdTime: z.string().optional(),
|
||||||
@@ -376,20 +467,26 @@ export const API = (_apiTypeCheck = {
|
|||||||
channels: [] as PrivateMessageChannel[],
|
channels: [] as PrivateMessageChannel[],
|
||||||
memberIdsByChannelId: {} as { [channelId: string]: string[] },
|
memberIdsByChannelId: {} as { [channelId: string]: string[] },
|
||||||
},
|
},
|
||||||
|
summary: 'List private message channel memberships',
|
||||||
|
tag: 'Messages',
|
||||||
},
|
},
|
||||||
'get-channel-messages': {
|
'get-channel-messages': {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
authed: true,
|
authed: true,
|
||||||
|
rateLimited: false,
|
||||||
props: z.object({
|
props: z.object({
|
||||||
channelId: z.coerce.number(),
|
channelId: z.coerce.number(),
|
||||||
limit: z.coerce.number(),
|
limit: z.coerce.number(),
|
||||||
id: z.coerce.number().optional(),
|
id: z.coerce.number().optional(),
|
||||||
}),
|
}),
|
||||||
returns: [] as PrivateChatMessage[],
|
returns: [] as PrivateChatMessage[],
|
||||||
|
summary: 'Retrieve messages for a private channel',
|
||||||
|
tag: 'Messages',
|
||||||
},
|
},
|
||||||
'get-channel-seen-time': {
|
'get-channel-seen-time': {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
authed: true,
|
authed: true,
|
||||||
|
rateLimited: false,
|
||||||
props: z.object({
|
props: z.object({
|
||||||
channelIds: z
|
channelIds: z
|
||||||
.array(z.coerce.number())
|
.array(z.coerce.number())
|
||||||
@@ -397,17 +494,31 @@ export const API = (_apiTypeCheck = {
|
|||||||
.transform(arrify),
|
.transform(arrify),
|
||||||
}),
|
}),
|
||||||
returns: [] as [number, string][],
|
returns: [] as [number, string][],
|
||||||
|
summary: 'Get last seen times for one or more channels',
|
||||||
|
tag: 'Messages',
|
||||||
},
|
},
|
||||||
'set-channel-seen-time': {
|
'set-channel-seen-time': {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
authed: true,
|
authed: true,
|
||||||
|
rateLimited: false,
|
||||||
props: z.object({
|
props: z.object({
|
||||||
channelId: z.coerce.number(),
|
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': {
|
'get-notifications': {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
authed: true,
|
authed: true,
|
||||||
|
rateLimited: false,
|
||||||
returns: [] as Notification[],
|
returns: [] as Notification[],
|
||||||
props: z
|
props: z
|
||||||
.object({
|
.object({
|
||||||
@@ -415,67 +526,202 @@ export const API = (_apiTypeCheck = {
|
|||||||
limit: z.coerce.number().gte(0).lte(1000).default(100),
|
limit: z.coerce.number().gte(0).lte(1000).default(100),
|
||||||
})
|
})
|
||||||
.strict(),
|
.strict(),
|
||||||
|
summary: 'Fetch notifications for the authenticated user',
|
||||||
|
tag: 'Notifications',
|
||||||
},
|
},
|
||||||
'create-private-user-message': {
|
'create-private-user-message': {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
authed: true,
|
authed: true,
|
||||||
|
rateLimited: true,
|
||||||
returns: {} as any,
|
returns: {} as any,
|
||||||
props: z.object({
|
props: z.object({
|
||||||
content: contentSchema,
|
content: contentSchema,
|
||||||
channelId: z.number(),
|
channelId: z.number(),
|
||||||
}),
|
}),
|
||||||
|
summary: 'Send a message in a private channel',
|
||||||
|
tag: 'Messages',
|
||||||
},
|
},
|
||||||
'create-private-user-message-channel': {
|
'create-private-user-message-channel': {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
authed: true,
|
authed: true,
|
||||||
|
rateLimited: true,
|
||||||
returns: {} as any,
|
returns: {} as any,
|
||||||
props: z.object({
|
props: z.object({
|
||||||
userIds: z.array(z.string()),
|
userIds: z.array(z.string()),
|
||||||
}),
|
}),
|
||||||
|
summary: 'Create a new private message channel between users',
|
||||||
|
tag: 'Messages',
|
||||||
},
|
},
|
||||||
'update-private-user-message-channel': {
|
'update-private-user-message-channel': {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
authed: true,
|
authed: true,
|
||||||
|
rateLimited: true,
|
||||||
returns: {} as any,
|
returns: {} as any,
|
||||||
props: z.object({
|
props: z.object({
|
||||||
channelId: z.number(),
|
channelId: z.number(),
|
||||||
notifyAfterTime: z.number(),
|
notifyAfterTime: z.number(),
|
||||||
}),
|
}),
|
||||||
|
summary: 'Update settings for a private message channel',
|
||||||
|
tag: 'Messages',
|
||||||
},
|
},
|
||||||
'leave-private-user-message-channel': {
|
'leave-private-user-message-channel': {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
authed: true,
|
authed: true,
|
||||||
|
rateLimited: true,
|
||||||
returns: {} as any,
|
returns: {} as any,
|
||||||
props: z.object({
|
props: z.object({
|
||||||
channelId: z.number(),
|
channelId: z.number(),
|
||||||
}),
|
}),
|
||||||
|
summary: 'Leave a private message channel',
|
||||||
|
tag: 'Messages',
|
||||||
},
|
},
|
||||||
'create-compatibility-question': {
|
'create-compatibility-question': {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
authed: true,
|
authed: true,
|
||||||
|
rateLimited: true,
|
||||||
returns: {} as any,
|
returns: {} as any,
|
||||||
props: z.object({
|
props: z.object({
|
||||||
question: z.string().min(1).max(MAX_COMPATIBILITY_QUESTION_LENGTH),
|
question: z.string().min(1).max(MAX_COMPATIBILITY_QUESTION_LENGTH),
|
||||||
options: z.record(z.string(), z.number()),
|
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',
|
||||||
|
authed: true,
|
||||||
|
rateLimited: true,
|
||||||
|
returns: {} as any,
|
||||||
|
props: z.object({
|
||||||
|
title: z.string().min(1),
|
||||||
|
isAnonymous: z.boolean(),
|
||||||
|
description: contentSchema,
|
||||||
|
}),
|
||||||
|
summary: 'Create a new vote/poll',
|
||||||
|
tag: 'Votes',
|
||||||
|
},
|
||||||
|
'vote': {
|
||||||
|
method: 'POST',
|
||||||
|
authed: true,
|
||||||
|
rateLimited: true,
|
||||||
|
returns: {} as any,
|
||||||
|
props: z.object({
|
||||||
|
voteId: z.number(),
|
||||||
|
priority: z.number(),
|
||||||
|
choice: z.enum(['for', 'abstain', 'against']),
|
||||||
|
}),
|
||||||
|
summary: 'Cast a vote on an existing poll',
|
||||||
|
tag: 'Votes',
|
||||||
},
|
},
|
||||||
'search-location': {
|
'search-location': {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
authed: false,
|
authed: true,
|
||||||
|
rateLimited: true,
|
||||||
returns: {} as any,
|
returns: {} as any,
|
||||||
props: z.object({
|
props: z.object({
|
||||||
term: z.string(),
|
term: z.string(),
|
||||||
limit: z.number().optional(),
|
limit: z.number().optional(),
|
||||||
}),
|
}),
|
||||||
|
summary: 'Search for a location by text',
|
||||||
|
tag: 'Locations',
|
||||||
},
|
},
|
||||||
'search-near-city': {
|
'search-near-city': {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
authed: false,
|
authed: true,
|
||||||
|
rateLimited: true,
|
||||||
returns: {} as any,
|
returns: {} as any,
|
||||||
props: z.object({
|
props: z.object({
|
||||||
cityId: z.string(),
|
cityId: z.string(),
|
||||||
radius: z.number().min(1).max(500),
|
radius: z.number().min(1).max(500),
|
||||||
}),
|
}),
|
||||||
|
summary: 'Find places near a GeoDB city ID within a radius',
|
||||||
|
tag: 'Locations',
|
||||||
|
},
|
||||||
|
'contact': {
|
||||||
|
method: 'POST',
|
||||||
|
authed: false,
|
||||||
|
rateLimited: true,
|
||||||
|
returns: {} as any,
|
||||||
|
props: z.object({
|
||||||
|
content: contentSchema,
|
||||||
|
userId: z.string().optional(),
|
||||||
|
}),
|
||||||
|
summary: 'Send a contact/support message',
|
||||||
|
tag: 'Contact',
|
||||||
|
},
|
||||||
|
'get-messages-count': {
|
||||||
|
method: 'GET',
|
||||||
|
authed: false,
|
||||||
|
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)
|
} as const)
|
||||||
|
|
||||||
@@ -488,8 +734,8 @@ export type ValidatedAPIParams<N extends APIPath> = z.output<
|
|||||||
>
|
>
|
||||||
|
|
||||||
export type APIResponse<N extends APIPath> = APISchema<N> extends {
|
export type APIResponse<N extends APIPath> = APISchema<N> extends {
|
||||||
returns: Record<string, any>
|
returns: Record<string, any>
|
||||||
}
|
}
|
||||||
? APISchema<N>['returns']
|
? APISchema<N>['returns']
|
||||||
: void
|
: void
|
||||||
|
|
||||||
|
|||||||
@@ -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 { User } from 'common/user'
|
||||||
import { removeUndefinedProps } from 'common/util/object'
|
import { removeUndefinedProps } from 'common/util/object'
|
||||||
|
|
||||||
@@ -22,6 +22,6 @@ export function toUserAPIResponse(user: User): FullUser {
|
|||||||
...user,
|
...user,
|
||||||
url: `https://${ENV_CONFIG.domain}/${user.username}`,
|
url: `https://${ENV_CONFIG.domain}/${user.username}`,
|
||||||
isAdmin: ENV_CONFIG.adminIds.includes(user.id),
|
isAdmin: ENV_CONFIG.adminIds.includes(user.id),
|
||||||
isTrustworthy: MOD_IDS.includes(user.id),
|
isTrustworthy: MOD_USERNAMES.includes(user.username),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ const genderType = z.string()
|
|||||||
// ])
|
// ])
|
||||||
const genderTypes = z.array(genderType)
|
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({
|
export const baseProfilesSchema = z.object({
|
||||||
// Required fields
|
// Required fields
|
||||||
age: z.number().min(18).max(100).optional(),
|
age: z.number().min(18).max(100).optional(),
|
||||||
@@ -49,15 +53,9 @@ export const baseProfilesSchema = z.object({
|
|||||||
pref_gender: genderTypes,
|
pref_gender: genderTypes,
|
||||||
pref_age_min: z.number().min(18).max(100).optional(),
|
pref_age_min: z.number().min(18).max(100).optional(),
|
||||||
pref_age_max: z.number().min(18).max(100).optional(),
|
pref_age_max: z.number().min(18).max(100).optional(),
|
||||||
pref_relation_styles: z.array(
|
pref_relation_styles: z.array(z.string()),
|
||||||
z.union([
|
|
||||||
z.literal('collaboration'),
|
|
||||||
z.literal('friendship'),
|
|
||||||
z.literal('relationship'),
|
|
||||||
])
|
|
||||||
),
|
|
||||||
wants_kids_strength: z.number(),
|
wants_kids_strength: z.number(),
|
||||||
looking_for_matches: z.boolean(),
|
looking_for_matches: zBoolean,
|
||||||
photo_urls: z.array(z.string()),
|
photo_urls: z.array(z.string()),
|
||||||
visibility: z.union([z.literal('public'), z.literal('member')]),
|
visibility: z.union([z.literal('public'), z.literal('member')]),
|
||||||
|
|
||||||
@@ -82,23 +80,25 @@ const optionalProfilesSchema = z.object({
|
|||||||
ethnicity: z.array(z.string()).optional(),
|
ethnicity: z.array(z.string()).optional(),
|
||||||
born_in_location: z.string().optional(),
|
born_in_location: z.string().optional(),
|
||||||
height_in_inches: z.number().optional(),
|
height_in_inches: z.number().optional(),
|
||||||
has_pets: z.boolean().optional(),
|
has_pets: zBoolean.optional().optional(),
|
||||||
education_level: z.string().optional(),
|
education_level: z.string().optional(),
|
||||||
last_online_time: z.string().optional(),
|
is_smoker: zBoolean.optional().optional(),
|
||||||
is_smoker: z.boolean().optional(),
|
|
||||||
drinks_per_month: z.number().min(0).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(),
|
has_kids: z.number().min(0).optional(),
|
||||||
university: z.string().optional(),
|
university: z.string().optional(),
|
||||||
occupation_title: z.string().optional(),
|
occupation_title: z.string().optional(),
|
||||||
occupation: z.string().optional(),
|
occupation: z.string().optional(),
|
||||||
company: z.string().optional(),
|
company: z.string().optional(),
|
||||||
comments_enabled: z.boolean().optional(),
|
comments_enabled: zBoolean.optional().optional(),
|
||||||
website: z.string().optional(),
|
website: z.string().optional(),
|
||||||
bio: contentSchema.optional().nullable(),
|
bio: contentSchema.optional().nullable(),
|
||||||
twitter: z.string().optional(),
|
twitter: z.string().optional(),
|
||||||
avatar_url: 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)
|
baseProfilesSchema.merge(optionalProfilesSchema)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user