mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-04-04 06:51:45 -04:00
Compare commits
429 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3cb8d51fc | ||
|
|
1bb9ac2006 | ||
|
|
07c8f74de7 | ||
|
|
e305fba93d | ||
|
|
ce680d6c8a | ||
|
|
5362403a04 | ||
|
|
abd77c4c4b | ||
|
|
e4a9337fab | ||
|
|
a9d9f0a190 | ||
|
|
a112350ad4 | ||
|
|
243e602fda | ||
|
|
b6d7955130 | ||
|
|
0277cc390d | ||
|
|
2d85b5c25a | ||
|
|
837a45dd91 | ||
|
|
b941960013 | ||
|
|
650b3f2469 | ||
|
|
3a1273dfac | ||
|
|
b7ab669adc | ||
|
|
affea2ce26 | ||
|
|
85ea90b9c8 | ||
|
|
0bfc714a41 | ||
|
|
ba9b3cfb06 | ||
|
|
1994697fa1 | ||
|
|
1c26b6381e | ||
|
|
f5daa3cdc8 | ||
|
|
8c71f6231a | ||
|
|
2f8e6147f6 | ||
|
|
2ff778a387 | ||
|
|
70c3493057 | ||
|
|
e62dac276a | ||
|
|
1115b4f1b0 | ||
|
|
c05af9ffcb | ||
|
|
2bb84d3992 | ||
|
|
2c90fc6cc8 | ||
|
|
d89bf2d92a | ||
|
|
f591cb13bb | ||
|
|
52720d2520 | ||
|
|
45473e0679 | ||
|
|
5e3c10d26a | ||
|
|
d0c58f753b | ||
|
|
20ba6f7ea9 | ||
|
|
27e93da06a | ||
|
|
c1a204e3be | ||
|
|
6be69b74e8 | ||
|
|
4b894363af | ||
|
|
cf843f66c4 | ||
|
|
cb98314bec | ||
|
|
4046cc19ef | ||
|
|
6787cdffa3 | ||
|
|
d583dbb945 | ||
|
|
bbd395b904 | ||
|
|
e1077e95a2 | ||
|
|
28f33da6d0 | ||
|
|
dbdaa9d7ec | ||
|
|
f4f009dc4a | ||
|
|
aacdd380c4 | ||
|
|
01f774ef7e | ||
|
|
57d7df63c8 | ||
|
|
f41c05a338 | ||
|
|
66526abeef | ||
|
|
3772d28129 | ||
|
|
3cc7222309 | ||
|
|
46082c5f64 | ||
|
|
cad1fd72e3 | ||
|
|
3a2e932628 | ||
|
|
1b3b40917b | ||
|
|
c181c571c4 | ||
|
|
8b094769e4 | ||
|
|
4b2b46d4f7 | ||
|
|
fc6628be08 | ||
|
|
07ce2780c6 | ||
|
|
c8b7b85391 | ||
|
|
0af6b9aff5 | ||
|
|
494e62720d | ||
|
|
8df2be1969 | ||
|
|
3c59be763a | ||
|
|
60a44b2ed1 | ||
|
|
e58a3ecb43 | ||
|
|
20025c825f | ||
|
|
aec30cd5b5 | ||
|
|
771cf887d5 | ||
|
|
78325cddfa | ||
|
|
1428ef1687 | ||
|
|
145f544ff1 | ||
|
|
28a582d9c4 | ||
|
|
19fc2b798b | ||
|
|
922decd252 | ||
|
|
0188a4ab51 | ||
|
|
c47e693e69 | ||
|
|
dd049dfb88 | ||
|
|
e6d64f2668 | ||
|
|
ee1e894e2f | ||
|
|
b3365cd773 | ||
|
|
920e0f37f2 | ||
|
|
fd7b4edc02 | ||
|
|
e689d0253c | ||
|
|
9efd3e4510 | ||
|
|
4fda21c582 | ||
|
|
76abe4ad28 | ||
|
|
e92f8afb46 | ||
|
|
e3907a3e64 | ||
|
|
5c9aa4f9f0 | ||
|
|
ae3b045772 | ||
|
|
6638d2b184 | ||
|
|
6a97045bad | ||
|
|
ba5beea17e | ||
|
|
7df3b301c6 | ||
|
|
ab81949927 | ||
|
|
f7c0d77e9c | ||
|
|
b7d1fd9903 | ||
|
|
e3b743f87b | ||
|
|
54e1106237 | ||
|
|
6a18d482e2 | ||
|
|
c46fc2a5bd | ||
|
|
e28263ff1f | ||
|
|
117b2d22e4 | ||
|
|
d61133ef74 | ||
|
|
ccf68b80c0 | ||
|
|
d2929a94ce | ||
|
|
7fd509b7e4 | ||
|
|
2c3314becc | ||
|
|
6815513ac3 | ||
|
|
e8cfc77902 | ||
|
|
7928e58d3b | ||
|
|
d8743a4b1c | ||
|
|
6626f23ef3 | ||
|
|
a0c1cf964b | ||
|
|
7437a2fb45 | ||
|
|
8ff5b8a577 | ||
|
|
cd434e2fb5 | ||
|
|
7734b689a3 | ||
|
|
ca55a93d5f | ||
|
|
6b7e3acf93 | ||
|
|
e865f75d95 | ||
|
|
af95174929 | ||
|
|
b6598f917c | ||
|
|
702d4ea1a2 | ||
|
|
dbe45d5181 | ||
|
|
4e11cc7ed1 | ||
|
|
2d5184a0ee | ||
|
|
1d2a2beb7a | ||
|
|
60dba612ba | ||
|
|
ea4047bc47 | ||
|
|
fab2316f28 | ||
|
|
ae708313aa | ||
|
|
812f6acac7 | ||
|
|
f56373fd73 | ||
|
|
5d9a1c1bf8 | ||
|
|
a3257cd4c0 | ||
|
|
67f8861d12 | ||
|
|
3fd37b21f3 | ||
|
|
9c1fbbd258 | ||
|
|
32fb79e9a2 | ||
|
|
ebecf18b6c | ||
|
|
403af8106f | ||
|
|
85ceee0b0d | ||
|
|
33f75420a2 | ||
|
|
a20767761b | ||
|
|
b13a40f892 | ||
|
|
a86f841b7e | ||
|
|
41c9da04b1 | ||
|
|
f42a1ad64f | ||
|
|
9c00fffb89 | ||
|
|
7b07610613 | ||
|
|
f1bc2f9dcb | ||
|
|
4a317018cb | ||
|
|
05219b2938 | ||
|
|
32edb4697c | ||
|
|
7e790ca353 | ||
|
|
939767cb42 | ||
|
|
31dc39fad7 | ||
|
|
243d22822a | ||
|
|
17d0fba831 | ||
|
|
05d003535b | ||
|
|
bd39bc290c | ||
|
|
4b203ae686 | ||
|
|
4e306af344 | ||
|
|
46489e25ff | ||
|
|
56d76922dc | ||
|
|
6ad3d3051f | ||
|
|
7598f32c56 | ||
|
|
46fcf042ad | ||
|
|
5962512a83 | ||
|
|
eaaae99dff | ||
|
|
688bbffd5e | ||
|
|
079ec8fc7e | ||
|
|
fba4436a08 | ||
|
|
d7f7e046ed | ||
|
|
e001787e5c | ||
|
|
cc4277a85c | ||
|
|
b49838c49a | ||
|
|
33ce2c9624 | ||
|
|
52d09aedd2 | ||
|
|
cf67ad0d81 | ||
|
|
20006049d0 | ||
|
|
a0ce449abe | ||
|
|
bd8b371c13 | ||
|
|
83433be1c8 | ||
|
|
7596906531 | ||
|
|
cb35813d16 | ||
|
|
55027c790a | ||
|
|
09ff56ad61 | ||
|
|
63e516fb4b | ||
|
|
0b2b60bd49 | ||
|
|
3e8470a216 | ||
|
|
e04746cde3 | ||
|
|
8c293c69eb | ||
|
|
cb96528752 | ||
|
|
8348953510 | ||
|
|
6d25937b56 | ||
|
|
d1f1fe945f | ||
|
|
4155beb7db | ||
|
|
374143172d | ||
|
|
6b4b0a9459 | ||
|
|
21789101fd | ||
|
|
8bfed9a6cc | ||
|
|
c36ceb7ed9 | ||
|
|
e80d8d701a | ||
|
|
f336e61304 | ||
|
|
70194541db | ||
|
|
8570f74a24 | ||
|
|
5b43382fc7 | ||
|
|
237ed0b5bf | ||
|
|
b01dcc6bde | ||
|
|
a433d1e095 | ||
|
|
ccc68f00ae | ||
|
|
756f0036eb | ||
|
|
54b8ab34bd | ||
|
|
59ebd539f1 | ||
|
|
7fad10d203 | ||
|
|
acb29c0600 | ||
|
|
398c7b92b7 | ||
|
|
95a9d8a50a | ||
|
|
95c018786d | ||
|
|
d7053dee14 | ||
|
|
f878521a40 | ||
|
|
40bbdb3fd9 | ||
|
|
7ac933160d | ||
|
|
0ac5ce7b41 | ||
|
|
152ed99727 | ||
|
|
a48633a074 | ||
|
|
b2ebf7e78e | ||
|
|
a5259f4c61 | ||
|
|
542152eadb | ||
|
|
ffc966f3b3 | ||
|
|
e45b24880f | ||
|
|
beac17cecf | ||
|
|
7ca682a4f9 | ||
|
|
07bc32d781 | ||
|
|
d59961f6cc | ||
|
|
945febffb6 | ||
|
|
bda34dddd0 | ||
|
|
ccb2eaaddf | ||
|
|
7880c391f1 | ||
|
|
ad4ea7328f | ||
|
|
763b74ef31 | ||
|
|
851d945545 | ||
|
|
80af2e9aeb | ||
|
|
473734bbd4 | ||
|
|
afe36f98be | ||
|
|
0888eb1a81 | ||
|
|
e2cbb5969d | ||
|
|
026a938e6f | ||
|
|
2da7e6d5d9 | ||
|
|
fd331dfaf9 | ||
|
|
e4a3e7a525 | ||
|
|
71695aba6c | ||
|
|
1aff8c1009 | ||
|
|
61a2e31175 | ||
|
|
c3d547f090 | ||
|
|
33d6dc1455 | ||
|
|
43c3ef591c | ||
|
|
79d56b0b4b | ||
|
|
1d505a2ae3 | ||
|
|
218de89583 | ||
|
|
94298e4609 | ||
|
|
274ea45cb8 | ||
|
|
d5322e1863 | ||
|
|
7131a5edaf | ||
|
|
ae977fbde7 | ||
|
|
f68321690a | ||
|
|
016841d6c2 | ||
|
|
635538b640 | ||
|
|
cf6989fcd7 | ||
|
|
9e324150c4 | ||
|
|
f6bf4b5b23 | ||
|
|
111f8809ca | ||
|
|
548c1d3ad9 | ||
|
|
c39dddf1db | ||
|
|
39d856f368 | ||
|
|
b2c4e46180 | ||
|
|
7622e864cb | ||
|
|
bc66c0334a | ||
|
|
babef5f032 | ||
|
|
f7f67b0ab0 | ||
|
|
9219671430 | ||
|
|
a4675246b2 | ||
|
|
e2d78722d8 | ||
|
|
d5c2f06784 | ||
|
|
26d9851c9e | ||
|
|
fbfb959de2 | ||
|
|
449b32d4bc | ||
|
|
a3c479ff92 | ||
|
|
51c97adce4 | ||
|
|
cde8a0d97f | ||
|
|
e257a10fdb | ||
|
|
6df9c5519a | ||
|
|
33a50b7e7e | ||
|
|
5029af9c5f | ||
|
|
7b61c70f6d | ||
|
|
87059494a3 | ||
|
|
e7f348e34e | ||
|
|
f953b5c10b | ||
|
|
d75f179a46 | ||
|
|
87713a7803 | ||
|
|
ac7091ae06 | ||
|
|
f4f3aa80f1 | ||
|
|
fa82a30907 | ||
|
|
2e81ef25f1 | ||
|
|
12ef6a891b | ||
|
|
fb3f5e5ace | ||
|
|
9537500fe1 | ||
|
|
bb423ba0e4 | ||
|
|
21db5cb481 | ||
|
|
078425f1b2 | ||
|
|
77e3b56b65 | ||
|
|
2ed23e05fa | ||
|
|
486a4a81f7 | ||
|
|
9a0f0c0892 | ||
|
|
4abed529d3 | ||
|
|
4293a8c24b | ||
|
|
60721eefb2 | ||
|
|
e5c8650df0 | ||
|
|
8e6d6584ea | ||
|
|
077f3ac1a3 | ||
|
|
04b8e21769 | ||
|
|
bc672db79a | ||
|
|
dc7be2d334 | ||
|
|
08c9f60010 | ||
|
|
0bb52e72f7 | ||
|
|
593c2ac024 | ||
|
|
8712424b89 | ||
|
|
c408d895b1 | ||
|
|
33c2121c8d | ||
|
|
fa151e79d3 | ||
|
|
58e65baa47 | ||
|
|
7bc57d8380 | ||
|
|
eed3e71113 | ||
|
|
08d98468c5 | ||
|
|
0ee66a264e | ||
|
|
c10f8ceb91 | ||
|
|
e3cb85271c | ||
|
|
2c43f3e1e7 | ||
|
|
29ea3a600a | ||
|
|
3ace0e80e8 | ||
|
|
79f460b9a2 | ||
|
|
7d41846b0a | ||
|
|
d8997f64cb | ||
|
|
8a9139633d | ||
|
|
a5191b440e | ||
|
|
c7d58905b5 | ||
|
|
b6df79b836 | ||
|
|
9975113eff | ||
|
|
e8431845b1 | ||
|
|
a3e51f06e3 | ||
|
|
ce447db6b5 | ||
|
|
8c14212e10 | ||
|
|
9976e085c1 | ||
|
|
6da973dd0c | ||
|
|
1dcf86e5ba | ||
|
|
3232e783e3 | ||
|
|
c66d82a06d | ||
|
|
a46ff44f99 | ||
|
|
669a95bfa9 | ||
|
|
4287fbae85 | ||
|
|
f2845eab91 | ||
|
|
40702e7832 | ||
|
|
9909bd41cb | ||
|
|
0dec7e1987 | ||
|
|
c5965fff89 | ||
|
|
53a1b4c415 | ||
|
|
773551e41d | ||
|
|
2df0e3df88 | ||
|
|
df39c2ee70 | ||
|
|
0bfd4d47b9 | ||
|
|
097617cfa0 | ||
|
|
b2968166df | ||
|
|
9116144de4 | ||
|
|
47e5e8bb28 | ||
|
|
32cae16045 | ||
|
|
4e4c946acf | ||
|
|
3e06b61eba | ||
|
|
04e2376829 | ||
|
|
709ef3f4ad | ||
|
|
2e7de541aa | ||
|
|
dc073ef0f9 | ||
|
|
a677df2fdd | ||
|
|
58c005194c | ||
|
|
16a5c3e408 | ||
|
|
8a4fb040e1 | ||
|
|
eab140e51d | ||
|
|
009d4bf91f | ||
|
|
ed094bbeca | ||
|
|
704bcb4619 | ||
|
|
fbdc594fa9 | ||
|
|
6a511adcf5 | ||
|
|
d55b04d22d | ||
|
|
96d0c90f8c | ||
|
|
2253a734b1 | ||
|
|
dca08a6d81 | ||
|
|
8a4dc44fbc | ||
|
|
e231f016d6 | ||
|
|
05250285c0 | ||
|
|
ce9ac99894 | ||
|
|
f76c39bd1c | ||
|
|
9ccb8d002b | ||
|
|
d112d4e739 | ||
|
|
7dfec75ac0 | ||
|
|
3fd27131e2 | ||
|
|
355fd7e6d1 | ||
|
|
c0b8df4ef9 | ||
|
|
cc3af74676 | ||
|
|
40a1e079a1 | ||
|
|
764769366c | ||
|
|
b433668628 | ||
|
|
cf27794776 | ||
|
|
238fed617f | ||
|
|
0d026d36d1 |
1300
.aiassistant/rules/guidelines.md
Normal file
1300
.aiassistant/rules/guidelines.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,3 @@
|
||||
|
||||
# use firebase emulator for running e2e tests
|
||||
NEXT_PUBLIC_FIREBASE_EMULATOR=false
|
||||
FIREBASE_AUTH_EMULATOR_HOST=127.0.0.1:9099
|
||||
FIREBASE_STORAGE_EMULATOR_HOST=127.0.0.1:9199
|
||||
|
||||
# You already have access to basic local functionality (UI, authentication, database read access).
|
||||
|
||||
# openssl enc -aes-256-cbc -salt -pbkdf2 -iter 100000 -in backend/shared/src/googleApplicationCredentials-dev.json -out secrets/googleApplicationCredentials-dev.json.enc
|
||||
|
||||
19
.env.test
Normal file
19
.env.test
Normal file
@@ -0,0 +1,19 @@
|
||||
# Database
|
||||
#DATABASE_URL=postgresql://test_user:test_password@localhost:5433/test_db
|
||||
#DIRECT_URL=postgresql://test_user:test_password@localhost:5433/test_db
|
||||
|
||||
# Firebase
|
||||
FIREBASE_AUTH_EMULATOR_HOST=127.0.0.1:9099
|
||||
FIREBASE_STORAGE_EMULATOR_HOST=127.0.0.1:9199
|
||||
FIRESTORE_EMULATOR_HOST=127.0.0.1:8080
|
||||
|
||||
# Next.js
|
||||
NEXT_PUBLIC_API_URL=http://localhost:8088
|
||||
NEXT_PUBLIC_FIREBASE_ENV=TEST
|
||||
NEXT_PUBLIC_FIREBASE_EMULATOR=true
|
||||
|
||||
# API
|
||||
NODE_ENV=test
|
||||
PORT=8088
|
||||
|
||||
ENVIRONMENT=DEV
|
||||
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
38
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -1,38 +0,0 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
|
||||
**Desktop (please complete the following information):**
|
||||
- OS: [e.g. iOS]
|
||||
- Browser [e.g. chrome, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Smartphone (please complete the following information):**
|
||||
- Device: [e.g. iPhone6]
|
||||
- OS: [e.g. iOS8.1]
|
||||
- Browser [e.g. stock browser, safari]
|
||||
- Version [e.g. 22]
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
63
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
63
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
name: Bug report
|
||||
description: Create a report to help us improve
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Bug description
|
||||
description: |
|
||||
A clear and concise description of what the bug is.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: |
|
||||
Steps to reproduce the behavior:
|
||||
1. Go to '...'
|
||||
2. Click on '...'
|
||||
3. Scroll down to '...'
|
||||
4. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: |
|
||||
A clear and concise description of what you expected to happen.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Screenshots
|
||||
description: |
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Info
|
||||
description: |
|
||||
- Browser: [e.g. chrome, safari]
|
||||
- Device (if mobile): [e.g. iPhone6]
|
||||
- Build info
|
||||
placeholder: |
|
||||
Build info from `Settings` -> `About`
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: |
|
||||
Add any other context about the problem here.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for contributing!
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
@@ -1,20 +0,0 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
title: ''
|
||||
labels: ''
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
40
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
40
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
name: Feature request
|
||||
description: Suggest an idea or improvement for this project
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Problem
|
||||
description: |
|
||||
A clear and concise description of what the problem is.
|
||||
placeholder: I'm always frustrated when [...]
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Solution
|
||||
description: |
|
||||
A clear and concise description of what you want to happen.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Alternatives
|
||||
description: |
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: |
|
||||
Add any other context or screenshots about the feature request here.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for contributing!
|
||||
36
.github/ISSUE_TEMPLATE/other.yml
vendored
36
.github/ISSUE_TEMPLATE/other.yml
vendored
@@ -1,15 +1,25 @@
|
||||
name: Other
|
||||
description: Any other question or issue
|
||||
|
||||
description: Use this only if no other issue type fits.
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Issue
|
||||
description: >
|
||||
A clear and concise description of the question or issue
|
||||
validations:
|
||||
required: true
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: >
|
||||
Thanks for contributing!
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Issue
|
||||
description: |
|
||||
A clear and concise description of the question or issue
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Info
|
||||
description: |
|
||||
- Browser: [e.g. chrome, safari]
|
||||
- Device (if mobile): [e.g. iPhone6]
|
||||
- Build info
|
||||
placeholder: |
|
||||
Build info from `Settings` -> `About`
|
||||
validations:
|
||||
required: false
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for contributing!
|
||||
|
||||
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -4,5 +4,5 @@
|
||||
- [ ] Tests added and passed if fixing a bug or adding a new feature.
|
||||
|
||||
### Description
|
||||
<!-- Describe your changes in detail -->
|
||||
|
||||
<!-- Describe your changes in detail -->
|
||||
|
||||
1294
.github/copilot-instructions.md
vendored
Normal file
1294
.github/copilot-instructions.md
vendored
Normal file
File diff suppressed because it is too large
Load Diff
70
.github/workflows/cd-android-live-update.yml
vendored
Normal file
70
.github/workflows/cd-android-live-update.yml
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
name: CD Android Live Update
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
paths:
|
||||
- 'android/capawesome.json'
|
||||
- '.github/workflows/cd-android-live-update.yml'
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # we need full history for git log
|
||||
|
||||
- name: Install jq
|
||||
run: sudo apt-get install -y jq
|
||||
|
||||
- name: Read current version
|
||||
id: current
|
||||
run: |
|
||||
current=$(jq -r '.version' android/capawesome.json)
|
||||
echo "version=$current" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Read previous version
|
||||
id: previous
|
||||
run: |
|
||||
# Get previous commit’s package.json (if it existed)
|
||||
if git show HEAD^:android/capawesome.json >/dev/null 2>&1; then
|
||||
previous=$(git show HEAD^:android/capawesome.json | jq -r '.version')
|
||||
else
|
||||
previous="none"
|
||||
fi
|
||||
echo "version=$previous" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check version change
|
||||
id: check
|
||||
run: |
|
||||
echo "current=${{ steps.current.outputs.version }}"
|
||||
echo "previous=${{ steps.previous.outputs.version }}"
|
||||
if [ "${{ steps.current.outputs.version }}" = "${{ steps.previous.outputs.version }}" ]; then
|
||||
echo "changed=false" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "changed=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.check.outputs.changed == 'true'
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.check.outputs.changed == 'true'
|
||||
run: yarn install
|
||||
|
||||
- name: Deploy Live Update
|
||||
if: steps.check.outputs.changed == 'true'
|
||||
env:
|
||||
NEXT_PUBLIC_FIREBASE_API_KEY: ${{ secrets.NEXT_PUBLIC_FIREBASE_API_KEY }}
|
||||
NEXT_PUBLIC_SUPABASE_INSTANCE_ID: ${{ secrets.NEXT_PUBLIC_SUPABASE_INSTANCE_ID }}
|
||||
NEXT_PUBLIC_SUPABASE_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_KEY }}
|
||||
CAPAWESOME_TOKEN: ${{ secrets.CAPAWESOME_TOKEN }}
|
||||
commitRef: ${{ github.head_ref || github.ref_name }}
|
||||
commitSha: ${{ github.sha }}
|
||||
run: yarn android-live-update
|
||||
8
.github/workflows/cd-api.yml
vendored
8
.github/workflows/cd-api.yml
vendored
@@ -1,10 +1,10 @@
|
||||
name: CD API
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
branches: [main, master]
|
||||
paths:
|
||||
- "backend/api/package.json"
|
||||
- ".github/workflows/cd-api.yml"
|
||||
- 'backend/api/package.json'
|
||||
- '.github/workflows/cd-api.yml'
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # we need full history for git log
|
||||
fetch-depth: 0 # we need full history for git log
|
||||
|
||||
- name: Install jq
|
||||
run: sudo apt-get install -y jq
|
||||
|
||||
11
.github/workflows/cd.yml
vendored
11
.github/workflows/cd.yml
vendored
@@ -2,13 +2,12 @@ name: CD
|
||||
|
||||
# Must select "Read and write permissions" in GitHub → Repo → Settings → Actions → General → Workflow permissions
|
||||
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
branches: [main, master]
|
||||
paths:
|
||||
- "package.json"
|
||||
- ".github/workflows/cd.yml"
|
||||
- 'package.json'
|
||||
- '.github/workflows/cd.yml'
|
||||
|
||||
jobs:
|
||||
release:
|
||||
@@ -18,7 +17,7 @@ jobs:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@master
|
||||
with:
|
||||
fetch-depth: 0 # To fetch all history for tags
|
||||
fetch-depth: 0 # To fetch all history for tags
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
@@ -32,4 +31,4 @@ jobs:
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git remote set-url origin https://x-access-token:${{ secrets.GITHUB_TOKEN }}@github.com/${{ github.repository }}
|
||||
./scripts/release.sh
|
||||
./scripts/release.sh
|
||||
|
||||
64
.github/workflows/ci-e2e.yml
vendored
Normal file
64
.github/workflows/ci-e2e.yml
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
name: E2E Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
name: E2E Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install Java (for Firebase emulators)
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '21' # Required for firebase-tools@15+
|
||||
|
||||
- name: Setup Supabase CLI
|
||||
uses: supabase/setup-cli@v1
|
||||
with:
|
||||
version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: npx playwright install chromium --with-deps
|
||||
|
||||
- name: Run E2E tests
|
||||
env:
|
||||
SKIP_DB_CLEANUP: true # Don't try to stop Docker in CI
|
||||
FIREBASE_TOKEN: 'dummy' # Suppresses auth warning
|
||||
# or
|
||||
run: |
|
||||
yarn test:e2e
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: test-results
|
||||
path: test-results/
|
||||
retention-days: 7
|
||||
52
.github/workflows/ci.yml
vendored
52
.github/workflows/ci.yml
vendored
@@ -1,17 +1,16 @@
|
||||
name: CI
|
||||
name: Jest Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
name: Tests
|
||||
name: Jest Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
@@ -21,42 +20,31 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'yarn'
|
||||
|
||||
- name: Install dependencies
|
||||
run: yarn install
|
||||
|
||||
- name: Type check
|
||||
run: echo skipping #npx tsc --noEmit
|
||||
run: yarn install --frozen-lockfile
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
run: yarn lint
|
||||
|
||||
- name: Type check
|
||||
run: yarn typecheck
|
||||
|
||||
- name: Run Jest tests
|
||||
env:
|
||||
NEXT_PUBLIC_FIREBASE_ENV: DEV
|
||||
run: |
|
||||
yarn test:coverage
|
||||
# npm install -g lcov-result-merger
|
||||
# mkdir coverage
|
||||
# lcov-result-merger \
|
||||
# "backend/api/coverage/lcov.info" \
|
||||
# "backend/shared/coverage/lcov.info" \
|
||||
# "backend/email/coverage/lcov.info" \
|
||||
# "common/coverage/lcov.info" \
|
||||
# "web/coverage/lcov.info" \
|
||||
# > coverage/lcov.info
|
||||
|
||||
# Optional: Playwright E2E tests
|
||||
- name: Install Playwright deps
|
||||
run: |
|
||||
npx playwright install chromium
|
||||
# npx playwright install --with-deps
|
||||
# npm install @playwright/test
|
||||
|
||||
- name: Run E2E tests
|
||||
run: |
|
||||
chmod +x scripts/e2e.sh
|
||||
./scripts/e2e.sh
|
||||
# npm install -g lcov-result-merger
|
||||
# mkdir coverage
|
||||
# lcov-result-merger \
|
||||
# "backend/api/coverage/lcov.info" \
|
||||
# "backend/shared/coverage/lcov.info" \
|
||||
# "backend/email/coverage/lcov.info" \
|
||||
# "common/coverage/lcov.info" \
|
||||
# "web/coverage/lcov.info" \
|
||||
# > coverage/lcov.info
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -39,7 +39,6 @@ yarn-error.log*
|
||||
.env.local
|
||||
.env.*
|
||||
.envrc
|
||||
supabase/*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
3
.husky/pre-commit
Normal file
3
.husky/pre-commit
Normal file
@@ -0,0 +1,3 @@
|
||||
npx lint-staged
|
||||
yarn --cwd=web lint-fix
|
||||
yarn --cwd=web lint
|
||||
1294
.junie/guidelines.md
Normal file
1294
.junie/guidelines.md
Normal file
File diff suppressed because it is too large
Load Diff
35
.prettierignore
Normal file
35
.prettierignore
Normal file
@@ -0,0 +1,35 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
.yarn
|
||||
|
||||
# Build outputs
|
||||
dist
|
||||
build
|
||||
.next
|
||||
out
|
||||
lib
|
||||
|
||||
# Generated files
|
||||
coverage
|
||||
*.min.js
|
||||
*.min.css
|
||||
|
||||
# Database / migrations
|
||||
**/*.sql
|
||||
|
||||
# Config / lock files
|
||||
yarn.lock
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
|
||||
# Android / iOS
|
||||
android
|
||||
ios
|
||||
capacitor.config.ts
|
||||
|
||||
# Playwright
|
||||
tests/reports
|
||||
playwright-report
|
||||
|
||||
coverage
|
||||
.vscode
|
||||
@@ -2,8 +2,11 @@
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": false,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"singleAttributePerLine": false,
|
||||
"bracketSpacing": false,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "all",
|
||||
"plugins": ["prettier-plugin-sql"],
|
||||
"overrides": [
|
||||
{
|
||||
|
||||
3
.vscode/launch.json
vendored
3
.vscode/launch.json
vendored
@@ -5,12 +5,13 @@
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Debug Jest Tests",
|
||||
"name": "Debug Current Test",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"runtimeArgs": [
|
||||
"--inspect-brk",
|
||||
"${workspaceRoot}/node_modules/.bin/jest",
|
||||
"${fileBasename}",
|
||||
"--runInBand"
|
||||
],
|
||||
"console": "integratedTerminal",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
trigger: always_on
|
||||
description:
|
||||
globs:
|
||||
description:
|
||||
globs:
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
@@ -33,14 +33,14 @@ Here's an example component from web in our style:
|
||||
import clsx from 'clsx'
|
||||
import Link from 'next/link'
|
||||
|
||||
import { isAdminId, isModId } from 'common/envs/constants'
|
||||
import { type Headline } from 'common/news'
|
||||
import { EditNewsButton } from 'web/components/news/edit-news-button'
|
||||
import { Carousel } from 'web/components/widgets/carousel'
|
||||
import { useUser } from 'web/hooks/use-user'
|
||||
import { track } from 'web/lib/service/analytics'
|
||||
import { DashboardEndpoints } from 'web/components/dashboard/dashboard-page'
|
||||
import { removeEmojis } from 'common/util/string'
|
||||
import {isAdminId, isModId} from 'common/envs/constants'
|
||||
import {type Headline} from 'common/news'
|
||||
import {EditNewsButton} from 'web/components/news/edit-news-button'
|
||||
import {Carousel} from 'web/components/widgets/carousel'
|
||||
import {useUser} from 'web/hooks/use-user'
|
||||
import {track} from 'web/lib/service/analytics'
|
||||
import {DashboardEndpoints} from 'web/components/dashboard/dashboard-page'
|
||||
import {removeEmojis} from 'common/util/string'
|
||||
|
||||
export function HeadlineTabs(props: {
|
||||
headlines: Headline[]
|
||||
@@ -50,20 +50,13 @@ export function HeadlineTabs(props: {
|
||||
notSticky?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const { headlines, endpoint, currentSlug, hideEmoji, notSticky, className } =
|
||||
props
|
||||
const {headlines, endpoint, currentSlug, hideEmoji, notSticky, className} = props
|
||||
const user = useUser()
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
className,
|
||||
'bg-canvas-50 w-full',
|
||||
!notSticky && 'sticky top-0 z-50'
|
||||
)}
|
||||
>
|
||||
<div className={clsx(className, 'bg-canvas-50 w-full', !notSticky && 'sticky top-0 z-50')}>
|
||||
<Carousel labelsParentClassName="gap-px">
|
||||
{headlines.map(({ id, slug, title }) => (
|
||||
{headlines.map(({id, slug, title}) => (
|
||||
<Tab
|
||||
key={id}
|
||||
label={hideEmoji ? removeEmojis(title) : title}
|
||||
@@ -137,9 +130,7 @@ Here's the definition of usePersistentInMemoryState:
|
||||
|
||||
```ts
|
||||
export const usePersistentInMemoryState = <T>(initialValue: T, key: string) => {
|
||||
const [state, setState] = useStateCheckEquality<T>(
|
||||
safeJsonParse(store[key]) ?? initialValue
|
||||
)
|
||||
const [state, setState] = useStateCheckEquality<T>(safeJsonParse(store[key]) ?? initialValue)
|
||||
|
||||
useEffect(() => {
|
||||
const storedValue = safeJsonParse(store[key]) ?? initialValue
|
||||
@@ -183,25 +174,19 @@ In `use-bets`, we have this hook to get live updates with useApiSubscription:
|
||||
```ts
|
||||
export const useContractBets = (
|
||||
contractId: string,
|
||||
opts?: APIParams<'bets'> & { enabled?: boolean }
|
||||
opts?: APIParams<'bets'> & {enabled?: boolean},
|
||||
) => {
|
||||
const { enabled = true, ...apiOptions } = {
|
||||
const {enabled = true, ...apiOptions} = {
|
||||
contractId,
|
||||
...opts,
|
||||
}
|
||||
const optionsKey = JSON.stringify(apiOptions)
|
||||
|
||||
const [newBets, setNewBets] = usePersistentInMemoryState<Bet[]>(
|
||||
[],
|
||||
`${optionsKey}-bets`
|
||||
)
|
||||
const [newBets, setNewBets] = usePersistentInMemoryState<Bet[]>([], `${optionsKey}-bets`)
|
||||
|
||||
const addBets = (bets: Bet[]) => {
|
||||
setNewBets((currentBets) => {
|
||||
const uniqueBets = sortBy(
|
||||
uniqBy([...currentBets, ...bets], 'id'),
|
||||
'createdTime'
|
||||
)
|
||||
const uniqueBets = sortBy(uniqBy([...currentBets, ...bets], 'id'), 'createdTime')
|
||||
return uniqueBets.filter((b) => !betShouldBeFiltered(b, apiOptions))
|
||||
})
|
||||
}
|
||||
@@ -236,12 +221,12 @@ export function broadcastUpdatedPrivateUser(userId: string) {
|
||||
broadcast(`private-user/${userId}`, {})
|
||||
}
|
||||
|
||||
export function broadcastUpdatedUser(user: Partial<User> & { id: string }) {
|
||||
broadcast(`user/${user.id}`, { user })
|
||||
export function broadcastUpdatedUser(user: Partial<User> & {id: string}) {
|
||||
broadcast(`user/${user.id}`, {user})
|
||||
}
|
||||
|
||||
export function broadcastUpdatedComment(comment: Comment) {
|
||||
broadcast(`user/${comment.onUserId}/comment`, { comment })
|
||||
broadcast(`user/${comment.onUserId}/comment`, {comment})
|
||||
}
|
||||
```
|
||||
|
||||
@@ -310,7 +295,7 @@ export const placeBet: APIHandler<'bet'> = async (props, auth) => {
|
||||
const isApi = auth.creds.kind === 'key'
|
||||
return await betsQueue.enqueueFn(
|
||||
() => placeBetMain(props, auth.uid, isApi),
|
||||
[props.contractId, auth.uid]
|
||||
[props.contractId, auth.uid],
|
||||
)
|
||||
}
|
||||
```
|
||||
@@ -332,7 +317,7 @@ const handlers = {
|
||||
We have two ways to access our postgres database.
|
||||
|
||||
```ts
|
||||
import { db } from 'web/lib/supabase/db'
|
||||
import {db} from 'web/lib/supabase/db'
|
||||
|
||||
db.from('profiles').select('*').eq('user_id', userId)
|
||||
```
|
||||
@@ -340,7 +325,7 @@ db.from('profiles').select('*').eq('user_id', userId)
|
||||
and
|
||||
|
||||
```ts
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
pg.oneOrNone<Row<'profiles'>>('select * from profiles where user_id = $1', [userId])
|
||||
@@ -353,13 +338,10 @@ The supabase client just uses the supabase client library, which is a wrapper ar
|
||||
Another example using the direct client:
|
||||
|
||||
```ts
|
||||
export const getUniqueBettorIds = async (
|
||||
contractId: string,
|
||||
pg: SupabaseDirectClient
|
||||
) => {
|
||||
export const getUniqueBettorIds = async (contractId: string, pg: SupabaseDirectClient) => {
|
||||
const res = await pg.manyOrNone(
|
||||
'select distinct user_id from contract_bets where contract_id = $1',
|
||||
[contractId]
|
||||
[contractId],
|
||||
)
|
||||
return res.map((r) => r.user_id as string)
|
||||
}
|
||||
@@ -411,12 +393,12 @@ Example usage:
|
||||
const query = renderSql(
|
||||
select('distinct user_id'),
|
||||
from('contract_bets'),
|
||||
where('contract_id = ${id}', { id }),
|
||||
where('contract_id = ${id}', {id}),
|
||||
orderBy('created_time desc'),
|
||||
limitValue != null && limit(limitValue)
|
||||
limitValue != null && limit(limitValue),
|
||||
)
|
||||
|
||||
const res = await pg.manyOrNone(query)
|
||||
```
|
||||
|
||||
Use these functions instead of string concatenation.
|
||||
Use these functions instead of string concatenation.
|
||||
|
||||
42
.windsurf/rules/next.md
Normal file
42
.windsurf/rules/next.md
Normal file
@@ -0,0 +1,42 @@
|
||||
---
|
||||
trigger: manual
|
||||
description:
|
||||
globs:
|
||||
---
|
||||
|
||||
### Translations
|
||||
|
||||
```typescript
|
||||
import {useT} from 'web/lib/locale'
|
||||
|
||||
const t = useT()
|
||||
t('common.key', 'English translations')
|
||||
```
|
||||
|
||||
Translations should go to the JSON files in `web/messages` (`de.json` and `fr.json`, as of now).
|
||||
|
||||
### Misc coding tips
|
||||
|
||||
We have many useful hooks that should be reused rather than rewriting them again.
|
||||
|
||||
---
|
||||
|
||||
We prefer using lodash functions instead of reimplementing them with for loops:
|
||||
|
||||
```ts
|
||||
import {keyBy, uniq} from 'lodash'
|
||||
|
||||
const betsByUserId = keyBy(bets, 'userId')
|
||||
const betIds = uniq(bets, (b) => b.id)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Instead of Sets, consider using lodash's uniq function:
|
||||
|
||||
```ts
|
||||
const betIds = uniq([])
|
||||
for (const id of betIds) {
|
||||
...
|
||||
}
|
||||
```
|
||||
@@ -17,23 +17,23 @@ diverse, inclusive, and healthy community.
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the
|
||||
- Focusing on what is best not just for us as individuals, but for the
|
||||
overall community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or
|
||||
- The use of sexualized language or imagery, and sexual attention or
|
||||
advances of any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email
|
||||
address, without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
@@ -106,7 +106,7 @@ Violating these terms may lead to a permanent ban.
|
||||
### 4. Permanent Ban
|
||||
|
||||
**Community Impact**: Demonstrating a pattern of violation of community
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
standards, including sustained inappropriate behavior, harassment of an
|
||||
individual, or aggression toward or disparagement of classes of individuals.
|
||||
|
||||
**Consequence**: A permanent ban from any sort of public interaction within
|
||||
|
||||
@@ -7,15 +7,20 @@ We welcome pull requests, but only if they meet the project's quality and design
|
||||
- Familiarity with Git and GitHub (basic commands, branching, forking, etc.)
|
||||
- A functioning development environment
|
||||
- Node.js, Python, or other relevant runtime/tools installed (check the `README.md`)
|
||||
- Read the [Development Documentation](docs/development.md) for project-specific setup and guidelines (adding languages,
|
||||
profile fields, etc.)
|
||||
|
||||
## Fork & Clone
|
||||
|
||||
1. **Fork the repository** using the GitHub UI.
|
||||
2. **Clone your fork** locally:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/your-username/Compass.git
|
||||
cd your-fork
|
||||
|
||||
```
|
||||
|
||||
3. **Add the upstream remote**:
|
||||
|
||||
```bash
|
||||
@@ -93,27 +98,26 @@ Or whatever command is defined in the repo.
|
||||
|
||||
When opening a pull request:
|
||||
|
||||
* **Title**: Describe what the PR does, clearly and specifically.
|
||||
* **Description**: Explain the context. Link related issues (use `Fixes #123` if applicable).
|
||||
* **Checklist**:
|
||||
|
||||
* [ ] My code is clean and follows the style guide
|
||||
* [ ] I’ve added or updated tests
|
||||
* [ ] I’ve run all tests and they pass
|
||||
* [ ] I’ve documented my changes (if necessary)
|
||||
- **Title**: Describe what the PR does, clearly and specifically.
|
||||
- **Description**: Explain the context. Link related issues (use `Fixes #123` if applicable).
|
||||
- **Checklist**:
|
||||
- [ ] My code is clean and follows the style guide
|
||||
- [ ] I’ve added or updated tests
|
||||
- [ ] I’ve run all tests and they pass
|
||||
- [ ] I’ve documented my changes (if necessary)
|
||||
|
||||
## Code Review Process
|
||||
|
||||
* PRs are reviewed by maintainers or core contributors.
|
||||
* If feedback is given, respond and push updates. Do **not** open new PRs for changes to an existing one.
|
||||
* PRs that are incomplete, sloppy, or violate the above will be closed.
|
||||
- PRs are reviewed by maintainers or core contributors.
|
||||
- If feedback is given, respond and push updates. Do **not** open new PRs for changes to an existing one.
|
||||
- PRs that are incomplete, sloppy, or violate the above will be closed.
|
||||
|
||||
## Don't Do This
|
||||
|
||||
* Don’t commit directly to `main`
|
||||
* Don’t submit multiple unrelated changes in a single PR
|
||||
* Don’t ignore CI/test failures
|
||||
* Don’t expect hand-holding—read the docs and the source first
|
||||
- Don’t commit directly to `main`
|
||||
- Don’t submit multiple unrelated changes in a single PR
|
||||
- Don’t ignore CI/test failures
|
||||
- Don’t expect hand-holding—read the docs and the source first
|
||||
|
||||
## Security Issues
|
||||
|
||||
@@ -122,4 +126,3 @@ Do **not** open public issues for security vulnerabilities. Email the developmen
|
||||
## License
|
||||
|
||||
By contributing, you agree that your code will be licensed under the same license as the rest of the project.
|
||||
|
||||
|
||||
114
README.md
114
README.md
@@ -1,10 +1,10 @@
|
||||
|
||||

|
||||
[](https://github.com/CompassConnections/Compass/actions/workflows/cd.yml)
|
||||
[](https://github.com/CompassConnections/Compass/actions/workflows/cd-api.yml)
|
||||
[](https://github.com/CompassConnections/Compass/actions/workflows/ci.yml)
|
||||
[](https://github.com/CompassConnections/Compass/actions/workflows/ci-e2e.yml)
|
||||
[](https://codecov.io/gh/CompassConnections/Compass)
|
||||
[](https://www.compassmeet.com/stats)
|
||||
[](https://www.compassmeet.com/stats)
|
||||
|
||||
# Compass
|
||||
|
||||
@@ -33,14 +33,17 @@ 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.
|
||||
|
||||
To have edit access to the ClickUp workspace, you need an admin to manually give you permission (one time thing). To do so, use your preferred option:
|
||||
|
||||
- Ask or DM an admin on [Discord](https://discord.gg/8Vd7jzqjun)
|
||||
- Email hello@compassmeet.com
|
||||
- Raise an issue on GitHub
|
||||
|
||||
If you want to add tasks without creating an account, you can simply email
|
||||
|
||||
```
|
||||
a.t.901810339879.u-276866260.b847aba1-2709-4f17-b4dc-565a6967c234@tasks.clickup.com
|
||||
```
|
||||
|
||||
Put the task title in the email subject and the task description in the email content.
|
||||
|
||||
Here is a tailored selection of things that would be very useful. If you want to help but don’t know where to start, just ask us on [Discord](https://discord.gg/8Vd7jzqjun).
|
||||
@@ -57,7 +60,7 @@ Here is a tailored selection of things that would be very useful. If you want to
|
||||
- [ ] Cover more than 90% with tests (unit, integration, e2e)
|
||||
- [x] Add Android mobile app
|
||||
- [ ] Add iOS mobile app
|
||||
- [ ] Add better onboarding (tooltips, modals, etc.)
|
||||
- [x] Add better onboarding (tooltips, modals, etc.)
|
||||
- [ ] Add modules to learn more about each other (personality test, conflict style, love languages, etc.)
|
||||
- [ ] Add modules to improve interpersonal skills (active listening, nonviolent communication, etc.)
|
||||
- [ ] Add calendar integration and scheduling
|
||||
@@ -72,6 +75,7 @@ Everything is open to anyone for collaboration, but the following ones are parti
|
||||
- [x] Add profile fields (intellectual interests, cause areas, personality type, etc.)
|
||||
- [ ] Add profile fields: conflict style
|
||||
- [ ] Add profile fields: timezone
|
||||
- [ ] Add translations: Italian, Dutch, Hindi, Chinese, etc.
|
||||
- [x] Add filters to search through remaining profile fields (politics, religion, education level, etc.)
|
||||
- [ ] Make the app more user-friendly and appealing (UI/UX)
|
||||
- [ ] Clean up terms and conditions (convert to Markdown)
|
||||
@@ -103,32 +107,38 @@ Below are the steps to contribute. If you have any trouble or questions, please
|
||||
### Installation
|
||||
|
||||
Fork the [repo](https://github.com/CompassConnections/Compass) on GitHub (button in top right). Then, clone your repo and navigating into it:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/<your-username>/Compass.git
|
||||
cd Compass
|
||||
```
|
||||
|
||||
Install `yarn` (if not already installed):
|
||||
|
||||
```bash
|
||||
npm install --global yarn
|
||||
```
|
||||
|
||||
Then, install the dependencies for this project:
|
||||
|
||||
```bash
|
||||
yarn install
|
||||
yarn install --frozen-lockfile
|
||||
```
|
||||
|
||||
### Tests
|
||||
|
||||
Make sure the tests pass:
|
||||
Make sure the Jest tests pass:
|
||||
|
||||
```bash
|
||||
yarn test
|
||||
```
|
||||
|
||||
If they don't and you can't find out why, simply raise an issue! Sometimes it's something on our end that we overlooked.
|
||||
|
||||
### Running the Development Server
|
||||
|
||||
Start the development server:
|
||||
|
||||
```bash
|
||||
yarn dev
|
||||
```
|
||||
@@ -137,11 +147,95 @@ Once the server is running, visit http://localhost:3000 to start using the app.
|
||||
|
||||
Note: it's normal if page loading locally is much slower than the deployed version. It can take up to 10 seconds, it would be great to improve that though!
|
||||
|
||||
#### Full isolation
|
||||
|
||||
Running `yarn dev:isolated` spins up a local Supabase and Firebase emulator instead of pointing at the shared remote
|
||||
database. This is strongly recommended for day-to-day development:
|
||||
|
||||
- **Freedom** — Reset, wipe, and reseed your local database as many times as you want without affecting other
|
||||
contributors.
|
||||
- **No conflicts** — Multiple contributors can work simultaneously without stepping on each other's data or schema.
|
||||
- **Works offline** — No internet required once services are started locally.
|
||||
- **Faster** — No network latency on every database query.
|
||||
|
||||
However, running in full isolation requires installing several heavy dependencies:
|
||||
|
||||
- **Docker** (~500MB) — runs the Supabase Postgres container
|
||||
- **Supabase CLI** — manages the local Supabase stack (10+ Docker containers, ~1-2GB RAM when running)
|
||||
- **Java 21+** (~300MB) — required by Firebase emulators
|
||||
- **Firebase CLI** — manages the local Firebase emulators
|
||||
|
||||
First startup is slow (30-60s) and the stack uses significant memory. If your machine has less than 8GB RAM, you may
|
||||
notice slowdowns.
|
||||
|
||||
If this feels like too much, you can skip isolation entirely — `yarn dev` works out of the box against the shared remote
|
||||
and is perfectly fine for most contributions, especially UI changes, wording fixes, or anything that doesn't touch the
|
||||
database or authentication.
|
||||
|
||||
###### Setup instructions
|
||||
|
||||
As always, don't hesitate to raise an issue if you run into any problems!
|
||||
|
||||
Docker
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian (native - recommended over snap)
|
||||
curl -fsSL https://get.docker.com | sudo sh
|
||||
sudo usermod -aG docker $USER
|
||||
# Log out and back in for group changes to take effect
|
||||
|
||||
# macOS
|
||||
brew install --cask docker
|
||||
# Or download from https://www.docker.com/products/docker-desktop
|
||||
|
||||
# Verify
|
||||
docker --version
|
||||
```
|
||||
|
||||
Supabase CLI
|
||||
|
||||
```bash
|
||||
# Verify it got installed from `yarn install`
|
||||
npx supabase --version
|
||||
```
|
||||
|
||||
Java 21+
|
||||
|
||||
```bash
|
||||
# Ubuntu/Debian
|
||||
sudo apt install openjdk-21-jdk
|
||||
|
||||
# macOS
|
||||
brew install openjdk@21
|
||||
|
||||
# Verify (must be 21+)
|
||||
java -version
|
||||
```
|
||||
|
||||
Firebase CLI
|
||||
|
||||
```bash
|
||||
npm install -g firebase-tools
|
||||
|
||||
# Verify
|
||||
firebase --version
|
||||
```
|
||||
|
||||
Run in isolation
|
||||
|
||||
```bash
|
||||
yarn dev:isolated
|
||||
```
|
||||
|
||||
Visit `http://localhost:3000` as usual. Your local database comes preloaded with synthetic test profiles so the app
|
||||
looks and feels like the real thing.
|
||||
|
||||
### Contributing
|
||||
|
||||
Now you can start contributing by making changes and submitting pull requests!
|
||||
|
||||
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
|
||||
@@ -154,9 +248,11 @@ If you are new to Typescript or the open-source space, you could start with smal
|
||||
##### Resources
|
||||
|
||||
There is a lof of documentation in the [docs](docs) folder and across the repo, namely:
|
||||
|
||||
- [Next.js.md](docs/Next.js.md) for core fundamentals about our web / page-rendering framework.
|
||||
- [knowledge.md](docs/knowledge.md) for general information about the project structure.
|
||||
- [development.md](docs/development.md) for additional instructions, such as adding new profile fields.
|
||||
- [development.md](docs/development.md) for additional instructions, such as adding new profile fields or languages.
|
||||
- [TESTING.md](docs/TESTING.md) for how to write tests.
|
||||
- [web](web) for the web.
|
||||
- [backend/api](backend/api) for the backend API.
|
||||
- [android](android) for the Android app.
|
||||
@@ -166,22 +262,26 @@ There are a lot of useful scripts you can use in the [scripts](scripts) folder.
|
||||
### Submission
|
||||
|
||||
Add the original repo as upstream for syncing:
|
||||
|
||||
```bash
|
||||
git remote add upstream https://github.com/CompassConnections/Compass.git
|
||||
```
|
||||
|
||||
Create a new branch for your changes:
|
||||
|
||||
```bash
|
||||
git checkout -b <branch-name>
|
||||
```
|
||||
|
||||
Make changes, then stage and commit:
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Describe your changes"
|
||||
```
|
||||
|
||||
Push branch to your fork:
|
||||
|
||||
```bash
|
||||
git push origin <branch-name>
|
||||
```
|
||||
@@ -193,6 +293,7 @@ Finally, open a Pull Request on GitHub from your `fork/<branch-name>` → `Compa
|
||||
Almost all the features will work out of the box, so you can skip this step and come back later if you need to test the following services: email, geolocation.
|
||||
|
||||
We can't make the following information public, for security and privacy reasons:
|
||||
|
||||
- Database, otherwise anyone could access all the user data (including private messages)
|
||||
- Firebase, otherwise anyone could remove users or modify the media files
|
||||
- Email, analytics, and location services, otherwise anyone could use the service plans Compass paid for and run up the bill.
|
||||
@@ -203,4 +304,5 @@ Contributors should use the default keys for local development. Production uses
|
||||
If you do need one of the few remaining services, you need to set them up and store your own secrets as environment variables. To do so, simply open `.env` and fill in the variables according to the instructions in the file.
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
This project is built on top of [manifold.love](https://github.com/sipec/polylove), an open-source dating platform licensed under the MIT License. We greatly appreciate their work and contributions to open-source, which have significantly aided in the development of some core features such as direct messaging, prompts, and email notifications. We invite the community to explore and contribute to other open-source projects like manifold.love as well, especially if you're interested in functionalities that deviate from Compass' ideals of deep, intentional connections.
|
||||
|
||||
@@ -9,4 +9,3 @@
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Contact the development team at hello@compassmeet.com to report a vulnerability. You should receive updates within a week.
|
||||
|
||||
|
||||
@@ -267,9 +267,9 @@ yarn build-sync-android
|
||||
|
||||
## Live Updates
|
||||
|
||||
To avoid releasing to the app stores after every code update in the web pages, we build the new bundle and store it in Capawesome Cloud (an alternative to Ionic).
|
||||
To avoid releasing to the app stores after every code update in the web pages, we build the new bundle and store it in Capawesome Cloud (an alternative to Ionic). To add a new update, increment the version number in [capawesome.json](capawesome.json) and push to main (or make a PR to main). A GitHub Action will automatically build the new bundle and push it to Capawesome.
|
||||
|
||||
First, you need to do this one-time setup:
|
||||
You can also do so locally if you have admin access. First, you need to do this one-time setup:
|
||||
```
|
||||
npm install -g @capawesome/cli@latest
|
||||
npx @capawesome/cli login
|
||||
@@ -277,12 +277,10 @@ npx @capawesome/cli login
|
||||
|
||||
Then, run this to build your local assets and push them to Capawesome. Once done, each mobile app user will receive a notice that there is a new update available, which they can approve to download.
|
||||
```
|
||||
yarn build-web-view
|
||||
npx @capawesome/cli apps:bundles:create --path web/out
|
||||
yarn android:live-update
|
||||
```
|
||||
|
||||
That's all. So you should run the lines above every time you want your web updates pushed to main (which essentially updates the web app) to update the mobile app as well.
|
||||
Maybe we should add it to our CD. For example we set a file with `{liveUpdateVersion: 1}` and run the live update each time a push to main increments that counter.
|
||||
There is a limit of 100 monthly active user per month, though. So we may need to pay or create our custom limit as we scale. Next plan is $9 / month and allows 1000 MAUs.
|
||||
|
||||
- ∞ Live Updates
|
||||
|
||||
@@ -8,8 +8,8 @@ android {
|
||||
applicationId "com.compassconnections.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 14
|
||||
versionName "1.1.3"
|
||||
versionCode 39
|
||||
versionName "1.8.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
@@ -52,6 +52,9 @@ dependencies {
|
||||
|
||||
implementation 'com.google.android.gms:play-services-auth:21.4.0'
|
||||
implementation 'com.google.firebase:firebase-auth:24.0.1'
|
||||
|
||||
implementation 'com.google.android.play:app-update:2.1.0'
|
||||
implementation 'com.google.android.play:app-update-ktx:2.1.0'
|
||||
}
|
||||
|
||||
apply from: 'capacitor.build.gradle'
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
android:supportsRtl="true"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:fitsSystemWindows="true"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:theme="@style/AppTheme">
|
||||
|
||||
<activity
|
||||
@@ -65,6 +66,7 @@
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.VIBRATE" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
|
||||
<uses-permission android:name="com.google.android.gms.permission.AD_ID" tools:node="remove" />
|
||||
|
||||
|
||||
@@ -1,17 +1,26 @@
|
||||
package com.compassconnections.app;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.ContentResolver;
|
||||
import android.content.ContentValues;
|
||||
import android.content.Context;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.media.MediaScannerConnection;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.os.Bundle;
|
||||
import android.os.Environment;
|
||||
import android.provider.MediaStore;
|
||||
import android.util.Log;
|
||||
import android.webkit.JavascriptInterface;
|
||||
import android.webkit.WebSettings;
|
||||
import android.webkit.WebView;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
import androidx.annotation.RequiresApi;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import com.capacitorjs.plugins.pushnotifications.PushNotificationsPlugin;
|
||||
@@ -19,54 +28,26 @@ import com.getcapacitor.BridgeActivity;
|
||||
import com.getcapacitor.BridgeWebViewClient;
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginHandle;
|
||||
import com.google.android.play.core.appupdate.AppUpdateInfo;
|
||||
import com.google.android.play.core.appupdate.AppUpdateManager;
|
||||
import com.google.android.play.core.appupdate.AppUpdateManagerFactory;
|
||||
import com.google.android.play.core.appupdate.AppUpdateOptions;
|
||||
import com.google.android.play.core.install.model.AppUpdateType;
|
||||
import com.google.android.play.core.install.model.UpdateAvailability;
|
||||
|
||||
import org.json.JSONException;
|
||||
import org.json.JSONObject;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import ee.forgr.capacitor.social.login.GoogleProvider;
|
||||
import ee.forgr.capacitor.social.login.ModifiedMainActivityForSocialLoginPlugin;
|
||||
import ee.forgr.capacitor.social.login.SocialLoginPlugin;
|
||||
|
||||
|
||||
//import android.app.NotificationChannel;
|
||||
//import android.app.NotificationManager;
|
||||
//import android.os.Build;
|
||||
//import com.google.firebase.messaging.RemoteMessage;
|
||||
//import com.capacitorjs.plugins.pushnotifications.MessagingService;
|
||||
|
||||
//public class MyMessagingService extends MessagingService {
|
||||
//
|
||||
// @Override
|
||||
// public void onMessageReceived(RemoteMessage remoteMessage) {
|
||||
// // TODO(developer): Handle FCM messages here.
|
||||
// // Not getting messages here? See why this may be: https://goo.gl/39bRNJ
|
||||
// Log.d(TAG, "From: " + remoteMessage.getFrom());
|
||||
//
|
||||
// // Check if message contains a data payload.
|
||||
// if (remoteMessage.getData().size() > 0) {
|
||||
// Log.d(TAG, "Message data payload: " + remoteMessage.getData());
|
||||
//
|
||||
// if (/* Check if data needs to be processed by long running job */ true) {
|
||||
// // For long-running tasks (10 seconds or more) use WorkManager.
|
||||
// scheduleJob();
|
||||
// } else {
|
||||
// // Handle message within 10 seconds
|
||||
// handleNow();
|
||||
// }
|
||||
//
|
||||
// }
|
||||
//
|
||||
// // Check if message contains a notification payload.
|
||||
// if (remoteMessage.getNotification() != null) {
|
||||
// Log.d(TAG, "Message Notification Body: " + remoteMessage.getNotification().getBody());
|
||||
// }
|
||||
//
|
||||
// // Also if you intend on generating your own notifications as a result of a received FCM
|
||||
// // message, here is where that should be initiated. See sendNotification method below.
|
||||
// }
|
||||
//}
|
||||
|
||||
|
||||
public class MainActivity extends BridgeActivity implements ModifiedMainActivityForSocialLoginPlugin {
|
||||
|
||||
// Declare this at class level
|
||||
@@ -91,13 +72,107 @@ public class MainActivity extends BridgeActivity implements ModifiedMainActivity
|
||||
}
|
||||
}
|
||||
|
||||
public static class NativeBridge {
|
||||
@JavascriptInterface
|
||||
public boolean isNativeApp() {
|
||||
return true;
|
||||
public class WebAppInterface {
|
||||
private final Context context;
|
||||
|
||||
public WebAppInterface(Context context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
public void downloadFile(String filename, String content) {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
// Android 10+ (API 29+) - Use MediaStore
|
||||
downloadFileModern(filename, content);
|
||||
} else {
|
||||
// Android 9 and below - Use legacy method
|
||||
downloadFileLegacy(filename, content);
|
||||
}
|
||||
|
||||
// Show success message
|
||||
runOnUiThread(() ->
|
||||
Toast.makeText(MainActivity.this, "File downloaded: " + filename, Toast.LENGTH_SHORT).show()
|
||||
);
|
||||
|
||||
} catch (IOException e) {
|
||||
Log.e("CompassApp", "Failed to download file", e);
|
||||
runOnUiThread(() ->
|
||||
Toast.makeText(MainActivity.this, "Download failed: " + e.getMessage(), Toast.LENGTH_SHORT).show()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// For Android 10+ (Scoped Storage)
|
||||
@RequiresApi(api = Build.VERSION_CODES.Q)
|
||||
private void downloadFileModern(String filename, String content) throws IOException {
|
||||
ContentResolver resolver = getContentResolver();
|
||||
ContentValues contentValues = new ContentValues();
|
||||
contentValues.put(MediaStore.MediaColumns.DISPLAY_NAME, filename);
|
||||
contentValues.put(MediaStore.MediaColumns.MIME_TYPE, getMimeType(filename));
|
||||
contentValues.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS);
|
||||
|
||||
Uri uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues);
|
||||
if (uri != null) {
|
||||
try (OutputStream outputStream = resolver.openOutputStream(uri)) {
|
||||
if (outputStream != null) {
|
||||
outputStream.write(content.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For Android 9 and below
|
||||
private void downloadFileLegacy(String filename, String content) throws IOException {
|
||||
File downloadsDir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
|
||||
if (!downloadsDir.exists()) {
|
||||
downloadsDir.mkdirs();
|
||||
}
|
||||
|
||||
File file = getUniqueFile(downloadsDir, filename);
|
||||
try (FileOutputStream fos = new FileOutputStream(file)) {
|
||||
fos.write(content.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
MediaScannerConnection.scanFile(context, new String[]{file.getAbsolutePath()}, null, null);
|
||||
}
|
||||
|
||||
private File getUniqueFile(File directory, String filename) {
|
||||
File file = new File(directory, filename);
|
||||
if (!file.exists()) {
|
||||
return file;
|
||||
}
|
||||
|
||||
// Add number suffix if file exists
|
||||
String name = filename.substring(0, filename.lastIndexOf("."));
|
||||
String extension = filename.substring(filename.lastIndexOf("."));
|
||||
|
||||
int counter = 1;
|
||||
while (file.exists()) {
|
||||
file = new File(directory, name + "(" + counter + ")" + extension);
|
||||
counter++;
|
||||
}
|
||||
return file;
|
||||
}
|
||||
|
||||
// Helper method to determine MIME type
|
||||
private String getMimeType(String filename) {
|
||||
String extension = filename.substring(filename.lastIndexOf(".") + 1).toLowerCase();
|
||||
return switch (extension) {
|
||||
case "txt" -> "text/plain";
|
||||
case "pdf" -> "application/pdf";
|
||||
case "json" -> "application/json";
|
||||
case "csv" -> "text/csv";
|
||||
case "html" -> "text/html";
|
||||
case "jpg", "jpeg" -> "image/jpeg";
|
||||
case "png" -> "image/png";
|
||||
default -> "application/octet-stream";
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
@@ -134,7 +209,7 @@ public class MainActivity extends BridgeActivity implements ModifiedMainActivity
|
||||
settings.setUserAgentString(settings.getUserAgentString() + " CompassAppWebView");
|
||||
|
||||
settings.setJavaScriptEnabled(true);
|
||||
webView.addJavascriptInterface(new NativeBridge(), "AndroidBridge");
|
||||
webView.addJavascriptInterface(new WebAppInterface(this), "AndroidBridge");
|
||||
|
||||
registerPlugin(PushNotificationsPlugin.class);
|
||||
// Initialize the Bridge with Push Notifications plugin
|
||||
@@ -143,6 +218,9 @@ public class MainActivity extends BridgeActivity implements ModifiedMainActivity
|
||||
// }});
|
||||
|
||||
askNotificationPermission();
|
||||
|
||||
appUpdateManager = AppUpdateManagerFactory.create(this);
|
||||
checkForUpdates();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -169,5 +247,69 @@ public class MainActivity extends BridgeActivity implements ModifiedMainActivity
|
||||
@Override
|
||||
public void IHaveModifiedTheMainActivityForTheUseWithSocialLoginPlugin() {
|
||||
}
|
||||
|
||||
private static final int UPDATE_REQUEST_CODE = 500;
|
||||
private AppUpdateManager appUpdateManager;
|
||||
private static final String TAG = "MainActivity";
|
||||
|
||||
private void checkForUpdates() {
|
||||
appUpdateManager.getAppUpdateInfo()
|
||||
.addOnSuccessListener(appUpdateInfo -> {
|
||||
if (appUpdateInfo.updateAvailability() == UpdateAvailability.UPDATE_AVAILABLE) {
|
||||
if (appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.IMMEDIATE)) {
|
||||
startImmediateUpdate(appUpdateInfo);
|
||||
} else if (appUpdateInfo.isUpdateTypeAllowed(AppUpdateType.FLEXIBLE)) {
|
||||
startFlexibleUpdate(appUpdateInfo);
|
||||
}
|
||||
}
|
||||
})
|
||||
.addOnFailureListener(exception -> {
|
||||
// Handle error - log it
|
||||
Log.e(TAG, "Failed to check For Updates", exception);
|
||||
});
|
||||
}
|
||||
|
||||
private void startImmediateUpdate(AppUpdateInfo appUpdateInfo) {
|
||||
AppUpdateOptions options = AppUpdateOptions.newBuilder(AppUpdateType.IMMEDIATE).build();
|
||||
|
||||
appUpdateManager.startUpdateFlow(appUpdateInfo, this, options)
|
||||
.addOnSuccessListener(result -> {
|
||||
Log.i(TAG, "Immediate update started successfully");
|
||||
})
|
||||
.addOnFailureListener(exception -> {
|
||||
Log.e(TAG, "Failed to start immediate update", exception);
|
||||
});
|
||||
}
|
||||
|
||||
private void startFlexibleUpdate(AppUpdateInfo appUpdateInfo) {
|
||||
AppUpdateOptions options = AppUpdateOptions.newBuilder(AppUpdateType.FLEXIBLE).build();
|
||||
|
||||
appUpdateManager.startUpdateFlow(appUpdateInfo, this, options)
|
||||
.addOnSuccessListener(result -> {
|
||||
Log.i(TAG, "Flexible update started successfully");
|
||||
})
|
||||
.addOnFailureListener(exception -> {
|
||||
Log.e(TAG, "Failed to start flexible update", exception);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public void onResume() {
|
||||
super.onResume();
|
||||
|
||||
// Check if an immediate update was interrupted
|
||||
appUpdateManager.getAppUpdateInfo().addOnSuccessListener(appUpdateInfo -> {
|
||||
if (appUpdateInfo.updateAvailability() == UpdateAvailability.DEVELOPER_TRIGGERED_UPDATE_IN_PROGRESS) {
|
||||
startImmediateUpdate(appUpdateInfo);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDestroy() {
|
||||
super.onDestroy();
|
||||
appUpdateManager = null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
3
android/capawesome.json
Normal file
3
android/capawesome.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"version": 24
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
module.exports = {
|
||||
plugins: ['lodash', 'unused-imports'],
|
||||
plugins: ['lodash', 'unused-imports', 'simple-import-sort'],
|
||||
extends: ['eslint:recommended'],
|
||||
ignorePatterns: ['dist', 'lib'],
|
||||
ignorePatterns: ['dist', 'lib', 'coverage'],
|
||||
env: {
|
||||
node: true,
|
||||
},
|
||||
@@ -16,15 +16,9 @@ module.exports = {
|
||||
project: ['./tsconfig.json', './tsconfig.test.json'],
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/ban-types': [
|
||||
'error',
|
||||
{
|
||||
extendDefaults: true,
|
||||
types: {
|
||||
'{}': false,
|
||||
},
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-empty-object-type': 'error', // replaces banning {}
|
||||
'@typescript-eslint/no-unsafe-function-type': 'error', // replaces banning Function
|
||||
'@typescript-eslint/no-wrapper-object-types': 'error', // replaces banning String, Number, etc.
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-extra-semi': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
@@ -41,10 +35,9 @@ module.exports = {
|
||||
},
|
||||
],
|
||||
rules: {
|
||||
'linebreak-style': [
|
||||
'error',
|
||||
process.platform === 'win32' ? 'windows' : 'unix',
|
||||
],
|
||||
'linebreak-style': ['error', process.platform === 'win32' ? 'windows' : 'unix'],
|
||||
'lodash/import-scope': [2, 'member'],
|
||||
'simple-import-sort/imports': 'error',
|
||||
'simple-import-sort/exports': 'error',
|
||||
},
|
||||
}
|
||||
|
||||
34
backend/api/.prettierignore
Normal file
34
backend/api/.prettierignore
Normal file
@@ -0,0 +1,34 @@
|
||||
# Dependencies
|
||||
node_modules
|
||||
.yarn
|
||||
|
||||
# Build outputs
|
||||
dist
|
||||
build
|
||||
.next
|
||||
out
|
||||
lib
|
||||
|
||||
# Generated files
|
||||
coverage
|
||||
*.min.js
|
||||
*.min.css
|
||||
|
||||
# Database / migrations
|
||||
**/*.sql
|
||||
|
||||
# Config / lock files
|
||||
yarn.lock
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
|
||||
# Android / iOS
|
||||
android
|
||||
ios
|
||||
capacitor.config.ts
|
||||
|
||||
# Playwright
|
||||
tests/reports
|
||||
playwright-report
|
||||
|
||||
coverage
|
||||
@@ -28,9 +28,11 @@ gcloud config set project YOUR_PROJECT_ID
|
||||
```
|
||||
|
||||
You also need `opentofu` and `docker`. Try running this (from root) on Linux or macOS for a faster install:
|
||||
|
||||
```bash
|
||||
./script/setup.sh
|
||||
```
|
||||
|
||||
If it doesn't work, you can install them manually (google how to install `opentofu` and `docker` for your OS).
|
||||
|
||||
### Setup
|
||||
@@ -105,8 +107,8 @@ gcloud iam service-accounts keys create keyfile.json --iam-account=ci-deployer@c
|
||||
|
||||
##### DNS
|
||||
|
||||
* After deployment, Terraform assigns a static external IP to this resource.
|
||||
* You can get it manually:
|
||||
- After deployment, Terraform assigns a static external IP to this resource.
|
||||
- You can get it manually:
|
||||
|
||||
```bash
|
||||
gcloud compute addresses describe api-lb-ip-2 --global --format="get(address)"
|
||||
@@ -120,11 +122,11 @@ Since Vercel manages your domain (`compassmeet.com`):
|
||||
3. Add an **A record** for your API subdomain:
|
||||
|
||||
| Type | Name | Value | TTL |
|
||||
|------|------|--------------|-------|
|
||||
| ---- | ---- | ------------ | ----- |
|
||||
| A | api | 34.123.45.67 | 600 s |
|
||||
|
||||
* `Name` is just the subdomain: `api` → `api.compassmeet.com`.
|
||||
* `Value` is the **external IP of the LB** from step 1.
|
||||
- `Name` is just the subdomain: `api` → `api.compassmeet.com`.
|
||||
- `Value` is the **external IP of the LB** from step 1.
|
||||
|
||||
Verify connectivity
|
||||
From your local machine:
|
||||
@@ -135,8 +137,8 @@ ping -c 3 api.compassmeet.com
|
||||
curl -I https://api.compassmeet.com
|
||||
```
|
||||
|
||||
* `nslookup` should return the LB IP (`34.123.45.67`).
|
||||
* `curl -I` should return `200 OK` from your service.
|
||||
- `nslookup` should return the LB IP (`34.123.45.67`).
|
||||
- `curl -I` should return `200 OK` from your service.
|
||||
|
||||
If SSL isn’t ready (may take 15 mins), check LB logs:
|
||||
|
||||
@@ -167,6 +169,7 @@ In root directory, run the local api with hot reload, along with all the other b
|
||||
To deploy the backend code, simply increment the version number in [package.json](package.json) and push to the `main` branch.
|
||||
|
||||
Or if you have access to the project on google cloud, run in this directory:
|
||||
|
||||
```bash
|
||||
./deploy-api.sh prod
|
||||
```
|
||||
@@ -195,4 +198,5 @@ docker rmi -f $(docker images -aq)
|
||||
The API doc is available at https://api.compassmeet.com. It's dynamically prepared in [app.ts](src/app.ts).
|
||||
|
||||
### Todo (Tests)
|
||||
- [ ] Finish get-supabase-token unit test when endpoint is implemented
|
||||
|
||||
- [ ] Finish get-supabase-token unit test when endpoint is implemented
|
||||
|
||||
@@ -27,13 +27,16 @@ SERVICE_NAME="api"
|
||||
GIT_REVISION=$(git rev-parse --short HEAD)
|
||||
GIT_COMMIT_DATE=$(git log -1 --format=%ci)
|
||||
GIT_COMMIT_AUTHOR=$(git log -1 --format='%an')
|
||||
GIT_COMMIT_MESSAGE=$(git log -1 --format='%s')
|
||||
echo "Git commit message: ${GIT_COMMIT_MESSAGE}"
|
||||
|
||||
cat > metadata.json << EOF
|
||||
{
|
||||
"git": {
|
||||
"revision": "${GIT_REVISION}",
|
||||
"commitDate": "${GIT_COMMIT_DATE}",
|
||||
"author": "${GIT_COMMIT_AUTHOR}"
|
||||
"author": "${GIT_COMMIT_AUTHOR}",
|
||||
"message": "${GIT_COMMIT_MESSAGE}"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: "api",
|
||||
script: "node",
|
||||
args: "--dns-result-order=ipv4first backend/api/lib/serve.js",
|
||||
env: {
|
||||
NODE_ENV: "production",
|
||||
NODE_PATH: "/usr/src/app/node_modules", // <- ensures Node finds tsconfig-paths
|
||||
PORT: 80,
|
||||
},
|
||||
instances: 1,
|
||||
exec_mode: "fork",
|
||||
autorestart: true,
|
||||
watch: false,
|
||||
// 4 GB on the box, give 3 GB to the JS heap
|
||||
node_args: "--max-old-space-size=3072",
|
||||
max_memory_restart: "3500M"
|
||||
}
|
||||
]
|
||||
};
|
||||
apps: [
|
||||
{
|
||||
name: 'api',
|
||||
script: 'node',
|
||||
args: '--dns-result-order=ipv4first backend/api/lib/serve.js',
|
||||
env: {
|
||||
NODE_ENV: 'production',
|
||||
NODE_PATH: '/usr/src/app/node_modules', // <- ensures Node finds tsconfig-paths
|
||||
PORT: 80,
|
||||
},
|
||||
instances: 1,
|
||||
exec_mode: 'fork',
|
||||
autorestart: true,
|
||||
watch: false,
|
||||
// 4 GB on the box, give 3 GB to the JS heap
|
||||
node_args: '--max-old-space-size=3072',
|
||||
max_memory_restart: '3500M',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -1,31 +1,28 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
|
||||
rootDir: '.',
|
||||
testMatch: [
|
||||
"<rootDir>/tests/**/*.test.ts",
|
||||
"<rootDir>/tests/**/*.spec.ts"
|
||||
rootDir: '.',
|
||||
testMatch: ['<rootDir>/tests/**/*.test.ts', '<rootDir>/tests/**/*.spec.ts'],
|
||||
|
||||
moduleNameMapper: {
|
||||
'^api/(.*)$': '<rootDir>/src/$1',
|
||||
'^shared/(.*)$': '<rootDir>/../shared/src/$1',
|
||||
'^common/(.*)$': '<rootDir>/../../common/src/$1',
|
||||
'^email/(.*)$': '<rootDir>/../email/emails/$1',
|
||||
},
|
||||
|
||||
moduleFileExtensions: ['tsx', 'ts', 'js', 'json'],
|
||||
clearMocks: true,
|
||||
|
||||
transform: {
|
||||
'^.+\\.tsx?$': [
|
||||
'ts-jest',
|
||||
{
|
||||
tsconfig: '<rootDir>/tsconfig.test.json',
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
moduleNameMapper: {
|
||||
"^api/(.*)$": "<rootDir>/src/$1",
|
||||
"^shared/(.*)$": "<rootDir>/../shared/src/$1",
|
||||
"^common/(.*)$": "<rootDir>/../../common/src/$1",
|
||||
"^email/(.*)$": "<rootDir>/../email/emails/$1"
|
||||
},
|
||||
|
||||
moduleFileExtensions: ["tsx","ts", "js", "json"],
|
||||
clearMocks: true,
|
||||
|
||||
globals: {
|
||||
'ts-jest': {
|
||||
tsconfig: "<rootDir>/tsconfig.test.json"
|
||||
}
|
||||
},
|
||||
|
||||
collectCoverageFrom: [
|
||||
"src/**/*.{ts,tsx}",
|
||||
"!src/**/*.d.ts"
|
||||
],
|
||||
};
|
||||
collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts'],
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"git": {
|
||||
"revision": "91f69ed",
|
||||
"commitDate": "2025-12-04 20:51:09+0100",
|
||||
"author": "MartinBraquet"
|
||||
"revision": "704bcb4",
|
||||
"commitDate": "2025-12-15 13:38:09 +0200",
|
||||
"author": "MartinBraquet",
|
||||
"message": "Increase API docs font size on mobile"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@compass/api",
|
||||
"description": "Backend API endpoints",
|
||||
"version": "1.0.11",
|
||||
"version": "1.12.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"watch:serve": "tsx watch src/serve.ts",
|
||||
@@ -17,8 +17,9 @@
|
||||
"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 package.json dist/backend/api && cp metadata.json dist && cp metadata.json dist/backend/api",
|
||||
"watch": "tsc -w",
|
||||
"verify": "yarn --cwd=../.. verify",
|
||||
"verify:dir": "npx eslint . --max-warnings 0",
|
||||
"lint": "npx eslint . --max-warnings 0",
|
||||
"lint-fix": "npx eslint . --fix",
|
||||
"typecheck": "yarn build && npx tsc --noEmit",
|
||||
"regen-types": "cd ../supabase && make ENV=prod regen-types",
|
||||
"regen-types-dev": "cd ../supabase && make ENV=dev regen-types-dev",
|
||||
"test": "jest --config jest.config.js",
|
||||
@@ -52,9 +53,9 @@
|
||||
"firebase-admin": "13.5.0",
|
||||
"gcp-metadata": "6.1.0",
|
||||
"jsonwebtoken": "9.0.0",
|
||||
"lodash": "4.17.21",
|
||||
"lodash": "4.17.23",
|
||||
"openapi-types": "12.1.3",
|
||||
"pg-promise": "11.4.1",
|
||||
"pg-promise": "11.5.5",
|
||||
"posthog-node": "4.11.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
|
||||
@@ -1,81 +1,93 @@
|
||||
import {API, type APIPath} from 'common/api/schema'
|
||||
import {APIError, pathWithPrefix} from 'common/api/utils'
|
||||
import cors from 'cors'
|
||||
import * as crypto from 'crypto'
|
||||
import express, {type ErrorRequestHandler, type RequestHandler} from 'express'
|
||||
import path from 'node:path'
|
||||
import {hrtime} from 'node:process'
|
||||
import {withMonitoringContext} from 'shared/monitoring/context'
|
||||
import {log} from 'shared/monitoring/log'
|
||||
import {metrics} from 'shared/monitoring/metrics'
|
||||
import {banUser} from './ban-user'
|
||||
import {blockUser, unblockUser} from './block-user'
|
||||
import {getCompatibleProfilesHandler} from './compatible-profiles'
|
||||
import {createComment} from './create-comment'
|
||||
import {createCompatibilityQuestion} from './create-compatibility-question'
|
||||
import {setCompatibilityAnswer} from './set-compatibility-answer'
|
||||
import {deleteCompatibilityAnswer} from './delete-compatibility-answer'
|
||||
import {createProfile} from './create-profile'
|
||||
import {createUser} from './create-user'
|
||||
import {getCompatibilityQuestions} from './get-compatibililty-questions'
|
||||
import {getLikesAndShips} from './get-likes-and-ships'
|
||||
import {getProfileAnswers} from './get-profile-answers'
|
||||
import {getProfiles} from './get-profiles'
|
||||
import {getSupabaseToken} from './get-supabase-token'
|
||||
import {getMe} from './get-me'
|
||||
import {hasFreeLike} from './has-free-like'
|
||||
import {health} from './health'
|
||||
import {type APIHandler, typedEndpoint} from './helpers/endpoint'
|
||||
import {hideComment} from './hide-comment'
|
||||
import {likeProfile} from './like-profile'
|
||||
import {markAllNotifsRead} from './mark-all-notifications-read'
|
||||
import {removePinnedPhoto} from './remove-pinned-photo'
|
||||
import {report} from './report'
|
||||
import {searchLocation} from './search-location'
|
||||
import {searchNearCity} from './search-near-city'
|
||||
import {shipProfiles} from './ship-profiles'
|
||||
import {starProfile} from './star-profile'
|
||||
import {updateProfile} from './update-profile'
|
||||
import {updateMe} from './update-me'
|
||||
import {deleteMe} from './delete-me'
|
||||
import {getCurrentPrivateUser} from './get-current-private-user'
|
||||
import {createPrivateUserMessage} from './create-private-user-message'
|
||||
|
||||
import {contact} from 'api/contact'
|
||||
import {createVote} from 'api/create-vote'
|
||||
import {deleteMessage} from 'api/delete-message'
|
||||
import {editMessage} from 'api/edit-message'
|
||||
import {getHiddenProfiles} from 'api/get-hidden-profiles'
|
||||
import {getMessagesCount} from 'api/get-messages-count'
|
||||
import {getOptions} from 'api/get-options'
|
||||
import {
|
||||
getChannelMemberships,
|
||||
getChannelMessagesEndpoint,
|
||||
getLastSeenChannelTime,
|
||||
setChannelLastSeenTime,
|
||||
} from 'api/get-private-messages'
|
||||
import {searchUsers} from './search-users'
|
||||
import {createPrivateUserMessageChannel} from './create-private-user-message-channel'
|
||||
import {leavePrivateUserMessageChannel} from './leave-private-user-message-channel'
|
||||
import {updatePrivateUserMessageChannel} from './update-private-user-message-channel'
|
||||
import {getNotifications} from './get-notifications'
|
||||
import {updateNotifSettings} from './update-notif-setting'
|
||||
import {setLastOnlineTime} from './set-last-online-time'
|
||||
import swaggerUi from "swagger-ui-express"
|
||||
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 {getUser} from 'api/get-user'
|
||||
import {hideProfile} from 'api/hide-profile'
|
||||
import {reactToMessage} from 'api/react-to-message'
|
||||
import {saveSubscription} from 'api/save-subscription'
|
||||
import {saveSubscriptionMobile} from 'api/save-subscription-mobile'
|
||||
import {sendSearchNotifications} from 'api/send-search-notifications'
|
||||
import {localSendTestEmail} from 'api/test'
|
||||
import {unhideProfile} from 'api/unhide-profile'
|
||||
import {updateOptions} from 'api/update-options'
|
||||
import {vote} from 'api/vote'
|
||||
import {API, type APIPath} from 'common/api/schema'
|
||||
import {APIError, pathWithPrefix} from 'common/api/utils'
|
||||
import {sendDiscordMessage} from 'common/discord/core'
|
||||
import {IS_LOCAL} from 'common/hosting/constants'
|
||||
import cors from 'cors'
|
||||
import * as crypto from 'crypto'
|
||||
import express, {type ErrorRequestHandler, type RequestHandler} from 'express'
|
||||
import {OpenAPIV3} from 'openapi-types'
|
||||
import {withMonitoringContext} from 'shared/monitoring/context'
|
||||
import {log} from 'shared/monitoring/log'
|
||||
import {metrics} from 'shared/monitoring/metrics'
|
||||
import swaggerUi from 'swagger-ui-express'
|
||||
import {z, ZodFirstPartyTypeKind, ZodTypeAny} from 'zod'
|
||||
|
||||
import {git} from './../metadata.json'
|
||||
import {z, ZodFirstPartyTypeKind, ZodTypeAny} from "zod";
|
||||
import {getUser} from "api/get-user";
|
||||
import {localSendTestEmail} from "api/test";
|
||||
import path from "node:path";
|
||||
import {saveSubscriptionMobile} from "api/save-subscription-mobile";
|
||||
import {IS_LOCAL} from "common/hosting/constants";
|
||||
import {editMessage} from "api/edit-message";
|
||||
import {reactToMessage} from "api/react-to-message";
|
||||
import {deleteMessage} from "api/delete-message";
|
||||
import {updateOptions} from "api/update-options";
|
||||
import {getOptions} from "api/get-options";
|
||||
import {version as pkgVersion} from './../package.json'
|
||||
import {banUser} from './ban-user'
|
||||
import {blockUser, unblockUser} from './block-user'
|
||||
import {cancelEvent} from './cancel-event'
|
||||
import {cancelRsvp} from './cancel-rsvp'
|
||||
import {getCompatibleProfilesHandler} from './compatible-profiles'
|
||||
import {createBookmarkedSearch} from './create-bookmarked-search'
|
||||
import {createComment} from './create-comment'
|
||||
import {createCompatibilityQuestion} from './create-compatibility-question'
|
||||
import {createEvent} from './create-event'
|
||||
import {createPrivateUserMessage} from './create-private-user-message'
|
||||
import {createPrivateUserMessageChannel} from './create-private-user-message-channel'
|
||||
import {createProfile} from './create-profile'
|
||||
import {createUser} from './create-user'
|
||||
import {deleteBookmarkedSearch} from './delete-bookmarked-search'
|
||||
import {deleteCompatibilityAnswer} from './delete-compatibility-answer'
|
||||
import {deleteMe} from './delete-me'
|
||||
import {getCompatibilityQuestions} from './get-compatibililty-questions'
|
||||
import {getCurrentPrivateUser} from './get-current-private-user'
|
||||
import {getEvents} from './get-events'
|
||||
import {getLikesAndShips} from './get-likes-and-ships'
|
||||
import {getMe} from './get-me'
|
||||
import {getNotifications} from './get-notifications'
|
||||
import {getProfileAnswers} from './get-profile-answers'
|
||||
import {getProfiles} from './get-profiles'
|
||||
import {getSupabaseToken} from './get-supabase-token'
|
||||
import {getUserDataExport} from './get-user-data-export'
|
||||
import {hasFreeLike} from './has-free-like'
|
||||
import {health} from './health'
|
||||
import {type APIHandler, typedEndpoint} from './helpers/endpoint'
|
||||
import {hideComment} from './hide-comment'
|
||||
import {leavePrivateUserMessageChannel} from './leave-private-user-message-channel'
|
||||
import {likeProfile} from './like-profile'
|
||||
import {markAllNotifsRead} from './mark-all-notifications-read'
|
||||
import {removePinnedPhoto} from './remove-pinned-photo'
|
||||
import {report} from './report'
|
||||
import {rsvpEvent} from './rsvp-event'
|
||||
import {searchLocation} from './search-location'
|
||||
import {searchNearCity} from './search-near-city'
|
||||
import {searchUsers} from './search-users'
|
||||
import {setCompatibilityAnswer} from './set-compatibility-answer'
|
||||
import {setLastOnlineTime} from './set-last-online-time'
|
||||
import {shipProfiles} from './ship-profiles'
|
||||
import {starProfile} from './star-profile'
|
||||
import {updateEvent} from './update-event'
|
||||
import {updateMe} from './update-me'
|
||||
import {updateNotifSettings} from './update-notif-setting'
|
||||
import {updatePrivateUserMessageChannel} from './update-private-user-message-channel'
|
||||
import {updateProfile} from './update-profile'
|
||||
|
||||
// const corsOptions: CorsOptions = {
|
||||
// origin: ['*'], // Only allow requests from this domain
|
||||
@@ -94,9 +106,7 @@ function cacheController(policy?: string): RequestHandler {
|
||||
|
||||
const requestMonitoring: RequestHandler = (req, _res, next) => {
|
||||
const traceContext = req.get('X-Cloud-Trace-Context')
|
||||
const traceId = traceContext
|
||||
? traceContext.split('/')[0]
|
||||
: crypto.randomUUID()
|
||||
const traceId = traceContext ? traceContext.split('/')[0] : crypto.randomUUID()
|
||||
const context = {endpoint: req.path, traceId}
|
||||
withMonitoringContext(context, () => {
|
||||
const startTs = hrtime.bigint()
|
||||
@@ -113,7 +123,7 @@ const apiErrorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
|
||||
if (error instanceof APIError) {
|
||||
log.info(error)
|
||||
if (!res.headersSent) {
|
||||
const output: { [k: string]: unknown } = {message: error.message}
|
||||
const output: {[k: string]: unknown} = {message: error.message}
|
||||
if (error.details != null) {
|
||||
output.details = error.details
|
||||
}
|
||||
@@ -130,92 +140,91 @@ const apiErrorHandler: ErrorRequestHandler = (error, _req, res, _next) => {
|
||||
export const app = express()
|
||||
app.use(requestMonitoring)
|
||||
|
||||
const schemaCache = new WeakMap<ZodTypeAny, any>()
|
||||
|
||||
const schemaCache = new WeakMap<ZodTypeAny, any>();
|
||||
|
||||
export function zodToOpenApiSchema(zodObj: ZodTypeAny,): any {
|
||||
export function zodToOpenApiSchema(zodObj: ZodTypeAny): any {
|
||||
if (schemaCache.has(zodObj)) {
|
||||
return schemaCache.get(zodObj);
|
||||
return schemaCache.get(zodObj)
|
||||
}
|
||||
|
||||
const def: any = (zodObj as any)._def;
|
||||
const typeName = def.typeName as ZodFirstPartyTypeKind;
|
||||
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);
|
||||
const placeholder: any = {}
|
||||
schemaCache.set(zodObj, placeholder)
|
||||
|
||||
let schema: any;
|
||||
let schema: any
|
||||
|
||||
switch (typeName) {
|
||||
case 'ZodString':
|
||||
schema = {type: 'string'};
|
||||
break;
|
||||
schema = {type: 'string'}
|
||||
break
|
||||
case 'ZodNumber':
|
||||
schema = {type: 'number'};
|
||||
break;
|
||||
schema = {type: 'number'}
|
||||
break
|
||||
case 'ZodBoolean':
|
||||
schema = {type: 'boolean'};
|
||||
break;
|
||||
schema = {type: 'boolean'}
|
||||
break
|
||||
case 'ZodEnum':
|
||||
schema = {type: 'string', enum: def.values};
|
||||
break;
|
||||
schema = {type: 'string', enum: def.values}
|
||||
break
|
||||
case 'ZodArray':
|
||||
schema = {type: 'array', items: zodToOpenApiSchema(def.type)};
|
||||
break;
|
||||
schema = {type: 'array', items: zodToOpenApiSchema(def.type)}
|
||||
break
|
||||
case 'ZodObject': {
|
||||
const shape = def.shape();
|
||||
const properties: Record<string, any> = {};
|
||||
const required: string[] = [];
|
||||
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);
|
||||
if (!child.isOptional()) required.push(key);
|
||||
const child = shape[key]
|
||||
properties[key] = zodToOpenApiSchema(child)
|
||||
if (!child.isOptional()) required.push(key)
|
||||
}
|
||||
|
||||
schema = {
|
||||
type: 'object',
|
||||
properties,
|
||||
...(required.length ? {required} : {}),
|
||||
};
|
||||
break;
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'ZodRecord':
|
||||
schema = {
|
||||
type: 'object',
|
||||
additionalProperties: zodToOpenApiSchema(def.valueType),
|
||||
};
|
||||
break;
|
||||
}
|
||||
break
|
||||
case 'ZodIntersection': {
|
||||
const left = zodToOpenApiSchema(def.left);
|
||||
const right = zodToOpenApiSchema(def.right);
|
||||
schema = {allOf: [left, right]};
|
||||
break;
|
||||
const left = zodToOpenApiSchema(def.left)
|
||||
const right = zodToOpenApiSchema(def.right)
|
||||
schema = {allOf: [left, right]}
|
||||
break
|
||||
}
|
||||
case 'ZodLazy':
|
||||
schema = {type: 'object', description: 'Lazy schema - details omitted'};
|
||||
break;
|
||||
schema = {type: 'object', description: 'Lazy schema - details omitted'}
|
||||
break
|
||||
case 'ZodUnion':
|
||||
schema = {
|
||||
oneOf: def.options.map((opt: ZodTypeAny) => zodToOpenApiSchema(opt)),
|
||||
};
|
||||
break;
|
||||
}
|
||||
break
|
||||
default:
|
||||
schema = {type: 'string'}; // fallback for unhandled
|
||||
schema = {type: 'string'} // fallback for unhandled
|
||||
}
|
||||
|
||||
Object.assign(placeholder, schema);
|
||||
return schema;
|
||||
Object.assign(placeholder, schema)
|
||||
return schema
|
||||
}
|
||||
|
||||
function generateSwaggerPaths(api: typeof API) {
|
||||
const paths: Record<string, any> = {};
|
||||
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;
|
||||
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 = {
|
||||
@@ -231,7 +240,7 @@ function generateSwaggerPaths(api: typeof API) {
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Include props in request body for POST/PUT
|
||||
if (config.props && ['post', 'put', 'patch'].includes(method)) {
|
||||
@@ -242,26 +251,26 @@ function generateSwaggerPaths(api: typeof API) {
|
||||
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();
|
||||
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
|
||||
}
|
||||
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] = {
|
||||
@@ -269,25 +278,24 @@ function generateSwaggerPaths(api: typeof API) {
|
||||
}
|
||||
|
||||
if (config.authed) {
|
||||
operation.security = [{BearerAuth: []}];
|
||||
operation.security = [{BearerAuth: []}]
|
||||
}
|
||||
}
|
||||
|
||||
return paths;
|
||||
return paths
|
||||
}
|
||||
|
||||
|
||||
const swaggerDocument: OpenAPIV3.Document = {
|
||||
openapi: "3.0.0",
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
title: "Compass API",
|
||||
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.\n Git: ${git.commitDate} (${git.revision}).`,
|
||||
version: pkgVersion,
|
||||
contact: {
|
||||
name: "Compass",
|
||||
email: "hello@compassmeet.com",
|
||||
url: "https://compassmeet.com"
|
||||
}
|
||||
name: 'Compass',
|
||||
email: 'hello@compassmeet.com',
|
||||
url: 'https://compassmeet.com',
|
||||
},
|
||||
},
|
||||
paths: generateSwaggerPaths(API),
|
||||
components: {
|
||||
@@ -303,81 +311,90 @@ const swaggerDocument: OpenAPIV3.Document = {
|
||||
name: 'x-api-key',
|
||||
},
|
||||
},
|
||||
}
|
||||
} as OpenAPIV3.Document;
|
||||
},
|
||||
} as OpenAPIV3.Document
|
||||
|
||||
// Triggers Missing parameter name at index 3: *; visit https://git.new/pathToRegexpError for info
|
||||
// May not be necessary
|
||||
// app.options('*', allowCorsUnrestricted)
|
||||
|
||||
const handlers: { [k in APIPath]: APIHandler<k> } = {
|
||||
health: health,
|
||||
'get-supabase-token': getSupabaseToken,
|
||||
'get-notifications': getNotifications,
|
||||
'mark-all-notifs-read': markAllNotifsRead,
|
||||
// 'user/:username': getUser,
|
||||
// 'user/:username/lite': getDisplayUser,
|
||||
'user/by-id/:id': getUser,
|
||||
// 'user/by-id/:id/lite': getDisplayUser,
|
||||
'user/by-id/:id/block': blockUser,
|
||||
'user/by-id/:id/unblock': unblockUser,
|
||||
'search-users': searchUsers,
|
||||
const handlers: {[k in APIPath]: APIHandler<k>} = {
|
||||
'ban-user': banUser,
|
||||
report: report,
|
||||
'create-user': createUser,
|
||||
'create-profile': createProfile,
|
||||
me: getMe,
|
||||
'me/private': getCurrentPrivateUser,
|
||||
'me/update': updateMe,
|
||||
'update-notif-settings': updateNotifSettings,
|
||||
'me/delete': deleteMe,
|
||||
'update-profile': updateProfile,
|
||||
'like-profile': likeProfile,
|
||||
'ship-profiles': shipProfiles,
|
||||
'get-likes-and-ships': getLikesAndShips,
|
||||
'has-free-like': hasFreeLike,
|
||||
'star-profile': starProfile,
|
||||
'get-profiles': getProfiles,
|
||||
'get-profile-answers': getProfileAnswers,
|
||||
'get-compatibility-questions': getCompatibilityQuestions,
|
||||
'remove-pinned-photo': removePinnedPhoto,
|
||||
'create-comment': createComment,
|
||||
'hide-comment': hideComment,
|
||||
'create-compatibility-question': createCompatibilityQuestion,
|
||||
'set-compatibility-answer': setCompatibilityAnswer,
|
||||
'delete-compatibility-answer': deleteCompatibilityAnswer,
|
||||
'create-vote': createVote,
|
||||
'vote': vote,
|
||||
'contact': contact,
|
||||
'compatible-profiles': getCompatibleProfilesHandler,
|
||||
'search-location': searchLocation,
|
||||
'search-near-city': searchNearCity,
|
||||
contact: contact,
|
||||
'create-bookmarked-search': createBookmarkedSearch,
|
||||
'create-comment': createComment,
|
||||
'create-compatibility-question': createCompatibilityQuestion,
|
||||
'create-private-user-message': createPrivateUserMessage,
|
||||
'create-private-user-message-channel': createPrivateUserMessageChannel,
|
||||
'update-private-user-message-channel': updatePrivateUserMessageChannel,
|
||||
'leave-private-user-message-channel': leavePrivateUserMessageChannel,
|
||||
'create-profile': createProfile,
|
||||
'create-user': createUser,
|
||||
'create-vote': createVote,
|
||||
'delete-bookmarked-search': deleteBookmarkedSearch,
|
||||
'delete-compatibility-answer': deleteCompatibilityAnswer,
|
||||
'delete-message': deleteMessage,
|
||||
'edit-message': editMessage,
|
||||
'get-channel-memberships': getChannelMemberships,
|
||||
'get-channel-messages': getChannelMessagesEndpoint,
|
||||
'get-channel-seen-time': getLastSeenChannelTime,
|
||||
'set-channel-seen-time': setChannelLastSeenTime,
|
||||
'get-compatibility-questions': getCompatibilityQuestions,
|
||||
'get-likes-and-ships': getLikesAndShips,
|
||||
'get-messages-count': getMessagesCount,
|
||||
'set-last-online-time': setLastOnlineTime,
|
||||
'get-notifications': getNotifications,
|
||||
'get-options': getOptions,
|
||||
'get-profile-answers': getProfileAnswers,
|
||||
'get-profiles': getProfiles,
|
||||
'get-supabase-token': getSupabaseToken,
|
||||
'has-free-like': hasFreeLike,
|
||||
'hide-comment': hideComment,
|
||||
'hide-profile': hideProfile,
|
||||
'unhide-profile': unhideProfile,
|
||||
'get-hidden-profiles': getHiddenProfiles,
|
||||
'leave-private-user-message-channel': leavePrivateUserMessageChannel,
|
||||
'like-profile': likeProfile,
|
||||
'mark-all-notifs-read': markAllNotifsRead,
|
||||
'me/delete': deleteMe,
|
||||
'me/data': getUserDataExport,
|
||||
'me/private': getCurrentPrivateUser,
|
||||
'me/update': updateMe,
|
||||
'react-to-message': reactToMessage,
|
||||
'remove-pinned-photo': removePinnedPhoto,
|
||||
'save-subscription': saveSubscription,
|
||||
'save-subscription-mobile': saveSubscriptionMobile,
|
||||
'create-bookmarked-search': createBookmarkedSearch,
|
||||
'delete-bookmarked-search': deleteBookmarkedSearch,
|
||||
'delete-message': deleteMessage,
|
||||
'edit-message': editMessage,
|
||||
'react-to-message': reactToMessage,
|
||||
'search-location': searchLocation,
|
||||
'search-near-city': searchNearCity,
|
||||
'search-users': searchUsers,
|
||||
'set-channel-seen-time': setChannelLastSeenTime,
|
||||
'set-compatibility-answer': setCompatibilityAnswer,
|
||||
'set-last-online-time': setLastOnlineTime,
|
||||
'ship-profiles': shipProfiles,
|
||||
'star-profile': starProfile,
|
||||
'update-notif-settings': updateNotifSettings,
|
||||
'update-options': updateOptions,
|
||||
'get-options': getOptions,
|
||||
// 'auth-google': authGoogle,
|
||||
'update-private-user-message-channel': updatePrivateUserMessageChannel,
|
||||
'update-profile': updateProfile,
|
||||
'user/by-id/:id': getUser,
|
||||
'user/by-id/:id/block': blockUser,
|
||||
'user/by-id/:id/unblock': unblockUser,
|
||||
vote: vote,
|
||||
// 'user/:username': getUser,
|
||||
// 'user/:username/lite': getDisplayUser,
|
||||
// 'user/by-id/:id/lite': getDisplayUser,
|
||||
'cancel-event': cancelEvent,
|
||||
'cancel-rsvp': cancelRsvp,
|
||||
'create-event': createEvent,
|
||||
'get-events': getEvents,
|
||||
'rsvp-event': rsvpEvent,
|
||||
'update-event': updateEvent,
|
||||
health: health,
|
||||
me: getMe,
|
||||
report: report,
|
||||
}
|
||||
|
||||
Object.entries(handlers).forEach(([path, handler]) => {
|
||||
const api = API[path as APIPath]
|
||||
const cache = cacheController((api as any).cache)
|
||||
const url = pathWithPrefix('/' + path as APIPath)
|
||||
const url = pathWithPrefix(('/' + path) as APIPath)
|
||||
|
||||
const apiRoute = [
|
||||
url,
|
||||
@@ -400,75 +417,73 @@ Object.entries(handlers).forEach(([path, handler]) => {
|
||||
})
|
||||
|
||||
// Internal Endpoints
|
||||
app.post(pathWithPrefix("/internal/send-search-notifications"),
|
||||
async (req, res) => {
|
||||
const apiKey = req.header("x-api-key");
|
||||
if (apiKey !== process.env.COMPASS_API_KEY) {
|
||||
return res.status(401).json({error: "Unauthorized"});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await sendSearchNotifications()
|
||||
return res.status(200).json(result)
|
||||
} catch (err) {
|
||||
console.error("Failed to send notifications:", err);
|
||||
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"});
|
||||
}
|
||||
app.post(pathWithPrefix('/internal/send-search-notifications'), async (req, res) => {
|
||||
const apiKey = req.header('x-api-key')
|
||||
if (apiKey !== process.env.COMPASS_API_KEY) {
|
||||
return res.status(401).json({error: 'Unauthorized'})
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await sendSearchNotifications()
|
||||
return res.status(200).json(result)
|
||||
} catch (err) {
|
||||
console.error('Failed to send notifications:', err)
|
||||
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'})
|
||||
}
|
||||
})
|
||||
|
||||
const responses = {
|
||||
200: {
|
||||
description: "Request successful",
|
||||
description: 'Request successful',
|
||||
content: {
|
||||
"application/json": {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
status: {type: "string", example: "success"}
|
||||
status: {type: 'string', example: 'success'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
401: {
|
||||
description: "Unauthorized (e.g., invalid or missing API key)",
|
||||
description: 'Unauthorized (e.g., invalid or missing API key)',
|
||||
content: {
|
||||
"application/json": {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
error: {type: "string", example: "Unauthorized"},
|
||||
error: {type: 'string', example: 'Unauthorized'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
500: {
|
||||
description: "Internal server error during request processing",
|
||||
description: 'Internal server error during request processing',
|
||||
content: {
|
||||
"application/json": {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: "object",
|
||||
type: 'object',
|
||||
properties: {
|
||||
error: {type: "string", example: "Internal server error"},
|
||||
error: {type: 'string', example: 'Internal server error'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
swaggerDocument.paths["/internal/send-search-notifications"] = {
|
||||
swaggerDocument.paths['/internal/send-search-notifications'] = {
|
||||
post: {
|
||||
summary: "Trigger daily search notifications",
|
||||
summary: 'Trigger daily search notifications',
|
||||
description:
|
||||
"Internal endpoint used by Compass schedulers to send daily notifications for bookmarked searches. Requires a valid `x-api-key` header.",
|
||||
tags: ["Internal"],
|
||||
'Internal endpoint used by Compass schedulers to send daily notifications for bookmarked searches. Requires a valid `x-api-key` header.',
|
||||
tags: ['Internal'],
|
||||
security: [
|
||||
{
|
||||
ApiKeyAuth: [],
|
||||
@@ -481,28 +496,25 @@ swaggerDocument.paths["/internal/send-search-notifications"] = {
|
||||
},
|
||||
} as any
|
||||
|
||||
|
||||
// Local Endpoints
|
||||
if (IS_LOCAL) {
|
||||
app.post(pathWithPrefix("/local/send-test-email"),
|
||||
async (req, res) => {
|
||||
if (!IS_LOCAL) {
|
||||
return res.status(401).json({error: "Unauthorized"});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await localSendTestEmail()
|
||||
return res.status(200).json(result)
|
||||
} catch (err) {
|
||||
return res.status(500).json({error: err});
|
||||
}
|
||||
app.post(pathWithPrefix('/local/send-test-email'), async (req, res) => {
|
||||
if (!IS_LOCAL) {
|
||||
return res.status(401).json({error: 'Unauthorized'})
|
||||
}
|
||||
);
|
||||
swaggerDocument.paths["/local/send-test-email"] = {
|
||||
|
||||
try {
|
||||
const result = await localSendTestEmail()
|
||||
return res.status(200).json(result)
|
||||
} catch (err) {
|
||||
return res.status(500).json({error: err})
|
||||
}
|
||||
})
|
||||
swaggerDocument.paths['/local/send-test-email'] = {
|
||||
post: {
|
||||
summary: "Send a test email",
|
||||
description: "Local endpoint to send a test email.",
|
||||
tags: ["Local"],
|
||||
summary: 'Send a test email',
|
||||
description: 'Local endpoint to send a test email.',
|
||||
tags: ['Local'],
|
||||
requestBody: {
|
||||
required: false,
|
||||
},
|
||||
@@ -511,18 +523,23 @@ if (IS_LOCAL) {
|
||||
} as any
|
||||
}
|
||||
|
||||
|
||||
const rootPath = pathWithPrefix("/")
|
||||
const rootPath = pathWithPrefix('/')
|
||||
app.get(
|
||||
rootPath,
|
||||
swaggerUi.setup(swaggerDocument, {
|
||||
customSiteTitle: 'Compass API Docs',
|
||||
customCssUrl: '/swagger.css',
|
||||
customJs: `
|
||||
const meta = document.createElement('meta');
|
||||
meta.name = 'viewport';
|
||||
meta.content = 'width=device-width, initial-scale=1';
|
||||
document.head.appendChild(meta);
|
||||
`,
|
||||
}),
|
||||
)
|
||||
app.use(rootPath, swaggerUi.serve)
|
||||
|
||||
app.use(express.static(path.join(__dirname, 'public')));
|
||||
app.use(express.static(path.join(__dirname, 'public')))
|
||||
|
||||
app.use(allowCorsUnrestricted, (req, res) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { APIError, APIHandler } from 'api/helpers/endpoint'
|
||||
import { trackPublicEvent } from 'shared/analytics'
|
||||
import { throwErrorIfNotMod } from 'shared/helpers/auth'
|
||||
import { isAdminId } from 'common/envs/constants'
|
||||
import { log } from 'shared/utils'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { updateUser } from 'shared/supabase/users'
|
||||
import {APIError, APIHandler} from 'api/helpers/endpoint'
|
||||
import {isAdminId} from 'common/envs/constants'
|
||||
import {trackPublicEvent} from 'shared/analytics'
|
||||
import {throwErrorIfNotMod} from 'shared/helpers/auth'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {updateUser} from 'shared/supabase/users'
|
||||
import {log} from 'shared/utils'
|
||||
|
||||
export const banUser: APIHandler<'ban-user'> = async (body, auth) => {
|
||||
const { userId, unban } = body
|
||||
const {userId, unban} = body
|
||||
const db = createSupabaseDirectClient()
|
||||
await throwErrorIfNotMod(auth.uid)
|
||||
if (isAdminId(userId)) throw new APIError(403, 'Cannot ban admin')
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { APIError, APIHandler } from './helpers/endpoint'
|
||||
import { FieldVal } from 'shared/supabase/utils'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { updatePrivateUser } from 'shared/supabase/users'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {updatePrivateUser} from 'shared/supabase/users'
|
||||
import {FieldVal} from 'shared/supabase/utils'
|
||||
|
||||
export const blockUser: APIHandler<'user/by-id/:id/block'> = async (
|
||||
{ id },
|
||||
auth
|
||||
) => {
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
|
||||
export const blockUser: APIHandler<'user/by-id/:id/block'> = async ({id}, auth) => {
|
||||
if (auth.uid === id) throw new APIError(400, 'You cannot block yourself')
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
@@ -20,10 +18,7 @@ export const blockUser: APIHandler<'user/by-id/:id/block'> = async (
|
||||
})
|
||||
}
|
||||
|
||||
export const unblockUser: APIHandler<'user/by-id/:id/unblock'> = async (
|
||||
{ id },
|
||||
auth
|
||||
) => {
|
||||
export const unblockUser: APIHandler<'user/by-id/:id/unblock'> = async ({id}, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
await pg.tx(async (tx) => {
|
||||
await updatePrivateUser(tx, auth.uid, {
|
||||
|
||||
46
backend/api/src/cancel-event.ts
Normal file
46
backend/api/src/cancel-event.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import {APIError, APIHandler} from 'api/helpers/endpoint'
|
||||
import {tryCatch} from 'common/util/try-catch'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {update} from 'shared/supabase/utils'
|
||||
|
||||
export const cancelEvent: APIHandler<'cancel-event'> = async (body, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
// Check if event exists and user is the creator
|
||||
const event = await pg.oneOrNone<{
|
||||
id: string
|
||||
creator_id: string
|
||||
status: string
|
||||
}>(
|
||||
`SELECT id, creator_id, status
|
||||
FROM events
|
||||
WHERE id = $1`,
|
||||
[body.eventId],
|
||||
)
|
||||
|
||||
if (!event) {
|
||||
throw new APIError(404, 'Event not found')
|
||||
}
|
||||
|
||||
if (event.creator_id !== auth.uid) {
|
||||
throw new APIError(403, 'Only the event creator can cancel this event')
|
||||
}
|
||||
|
||||
if (event.status === 'cancelled') {
|
||||
throw new APIError(400, 'Event is already cancelled')
|
||||
}
|
||||
|
||||
// Update event status to cancelled
|
||||
const {error} = await tryCatch(
|
||||
update(pg, 'events', 'id', {
|
||||
status: 'cancelled',
|
||||
id: body.eventId,
|
||||
}),
|
||||
)
|
||||
|
||||
if (error) {
|
||||
throw new APIError(500, 'Failed to cancel event: ' + error.message)
|
||||
}
|
||||
|
||||
return {success: true}
|
||||
}
|
||||
38
backend/api/src/cancel-rsvp.ts
Normal file
38
backend/api/src/cancel-rsvp.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import {APIError, APIHandler} from 'api/helpers/endpoint'
|
||||
import {tryCatch} from 'common/util/try-catch'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
export const cancelRsvp: APIHandler<'cancel-rsvp'> = async (body, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
// Check if RSVP exists
|
||||
const rsvp = await pg.oneOrNone<{
|
||||
id: string
|
||||
}>(
|
||||
`SELECT id
|
||||
FROM events_participants
|
||||
WHERE event_id = $1
|
||||
AND user_id = $2`,
|
||||
[body.eventId, auth.uid],
|
||||
)
|
||||
|
||||
if (!rsvp) {
|
||||
throw new APIError(404, 'RSVP not found')
|
||||
}
|
||||
|
||||
// Delete the RSVP
|
||||
const {error} = await tryCatch(
|
||||
pg.none(
|
||||
`DELETE
|
||||
FROM events_participants
|
||||
WHERE id = $1`,
|
||||
[rsvp.id],
|
||||
),
|
||||
)
|
||||
|
||||
if (error) {
|
||||
throw new APIError(500, 'Failed to cancel RSVP: ' + error.message)
|
||||
}
|
||||
|
||||
return {success: true}
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
import {type APIHandler} from 'api/helpers/endpoint'
|
||||
import {createSupabaseDirectClient} from "shared/supabase/init";
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
export const getCompatibleProfilesHandler: APIHandler<'compatible-profiles'> = async (props) => {
|
||||
return getCompatibleProfiles(props.userId)
|
||||
}
|
||||
|
||||
export const getCompatibleProfiles = async (
|
||||
userId: string,
|
||||
) => {
|
||||
export const getCompatibleProfiles = async (userId: string) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
const scores = await pg.map(
|
||||
`select *
|
||||
@@ -15,7 +13,7 @@ export const getCompatibleProfiles = async (
|
||||
where score is not null
|
||||
and (user_id_1 = $1 or user_id_2 = $1)`,
|
||||
[userId],
|
||||
(r) => [r.user_id_1 == userId ? r.user_id_2 : r.user_id_1, {score: r.score}] as const
|
||||
(r) => [r.user_id_1 == userId ? r.user_id_2 : r.user_id_1, {score: r.score}] as const,
|
||||
)
|
||||
|
||||
const profileCompatibilityScores = Object.fromEntries(scores)
|
||||
|
||||
@@ -1,24 +1,22 @@
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {sendDiscordMessage} from 'common/discord/core'
|
||||
import {jsonToMarkdown} from 'common/md'
|
||||
import {tryCatch} from 'common/util/try-catch'
|
||||
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";
|
||||
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
|
||||
// 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
|
||||
) => {
|
||||
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')
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import {APIHandler} from './helpers/endpoint'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
import {APIHandler} from './helpers/endpoint'
|
||||
|
||||
export const createBookmarkedSearch: APIHandler<'create-bookmarked-search'> = async (
|
||||
props,
|
||||
auth
|
||||
auth,
|
||||
) => {
|
||||
const creator_id = auth.uid
|
||||
const {search_filters, location = null, search_name = null} = props
|
||||
@@ -16,7 +17,7 @@ export const createBookmarkedSearch: APIHandler<'create-bookmarked-search'> = as
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING *
|
||||
`,
|
||||
[creator_id, search_filters, location, search_name]
|
||||
[creator_id, search_filters, location, search_name],
|
||||
)
|
||||
|
||||
return inserted
|
||||
|
||||
@@ -1,32 +1,25 @@
|
||||
import { APIError, APIHandler } from 'api/helpers/endpoint'
|
||||
import { type JSONContent } from '@tiptap/core'
|
||||
import { getPrivateUser, getUser } from 'shared/utils'
|
||||
import {
|
||||
createSupabaseDirectClient,
|
||||
SupabaseDirectClient,
|
||||
} from 'shared/supabase/init'
|
||||
import { getNotificationDestinationsForUser } from 'common/user-notification-preferences'
|
||||
import { Notification } from 'common/notifications'
|
||||
import { insertNotificationToSupabase } from 'shared/supabase/notifications'
|
||||
import { User } from 'common/user'
|
||||
import { richTextToString } from 'common/util/parse'
|
||||
import {type JSONContent} from '@tiptap/core'
|
||||
import {APIError, APIHandler} from 'api/helpers/endpoint'
|
||||
import {Notification} from 'common/notifications'
|
||||
import {convertComment} from 'common/supabase/comment'
|
||||
import {type Row} from 'common/supabase/utils'
|
||||
import {User} from 'common/user'
|
||||
import {getNotificationDestinationsForUser} from 'common/user-notification-preferences'
|
||||
import {richTextToString} from 'common/util/parse'
|
||||
import * as crypto from 'crypto'
|
||||
import { sendNewEndorsementEmail } from 'email/functions/helpers'
|
||||
import { type Row } from 'common/supabase/utils'
|
||||
import { broadcastUpdatedComment } from 'shared/websockets/helpers'
|
||||
import { convertComment } from 'common/supabase/comment'
|
||||
import {sendNewEndorsementEmail} from 'email/functions/helpers'
|
||||
import {createSupabaseDirectClient, SupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {insertNotificationToSupabase} from 'shared/supabase/notifications'
|
||||
import {getPrivateUser, getUser} from 'shared/utils'
|
||||
import {broadcastUpdatedComment} from 'shared/websockets/helpers'
|
||||
|
||||
export const MAX_COMMENT_JSON_LENGTH = 20000
|
||||
|
||||
export const createComment: APIHandler<'create-comment'> = async (
|
||||
{ userId, content: submittedContent, replyToCommentId },
|
||||
auth
|
||||
{userId, content: submittedContent, replyToCommentId},
|
||||
auth,
|
||||
) => {
|
||||
const { creator, content } = await validateComment(
|
||||
userId,
|
||||
auth.uid,
|
||||
submittedContent
|
||||
)
|
||||
const {creator, content} = await validateComment(userId, auth.uid, submittedContent)
|
||||
|
||||
const onUser = await getUser(userId)
|
||||
if (!onUser) throw new APIError(404, 'User not found')
|
||||
@@ -43,7 +36,7 @@ export const createComment: APIHandler<'create-comment'> = async (
|
||||
userId,
|
||||
content,
|
||||
replyToCommentId,
|
||||
]
|
||||
],
|
||||
)
|
||||
if (onUser.id !== creator.id)
|
||||
await createNewCommentOnProfileNotification(
|
||||
@@ -51,19 +44,15 @@ export const createComment: APIHandler<'create-comment'> = async (
|
||||
creator,
|
||||
richTextToString(content),
|
||||
comment.id,
|
||||
pg
|
||||
pg,
|
||||
)
|
||||
|
||||
broadcastUpdatedComment(convertComment(comment))
|
||||
|
||||
return { status: 'success' }
|
||||
return {status: 'success'}
|
||||
}
|
||||
|
||||
const validateComment = async (
|
||||
userId: string,
|
||||
creatorId: string,
|
||||
content: JSONContent
|
||||
) => {
|
||||
const validateComment = async (userId: string, creatorId: string, content: JSONContent) => {
|
||||
const creator = await getUser(creatorId)
|
||||
|
||||
if (!creator) throw new APIError(401, 'Your account was not found')
|
||||
@@ -78,10 +67,10 @@ const validateComment = async (
|
||||
if (JSON.stringify(content).length > MAX_COMMENT_JSON_LENGTH) {
|
||||
throw new APIError(
|
||||
400,
|
||||
`Comment is too long; should be less than ${MAX_COMMENT_JSON_LENGTH} as a JSON string.`
|
||||
`Comment is too long; should be less than ${MAX_COMMENT_JSON_LENGTH} as a JSON string.`,
|
||||
)
|
||||
}
|
||||
return { content, creator }
|
||||
return {content, creator}
|
||||
}
|
||||
|
||||
const createNewCommentOnProfileNotification = async (
|
||||
@@ -89,14 +78,16 @@ const createNewCommentOnProfileNotification = async (
|
||||
creator: User,
|
||||
sourceText: string,
|
||||
commentId: number,
|
||||
pg: SupabaseDirectClient
|
||||
pg: SupabaseDirectClient,
|
||||
) => {
|
||||
const privateUser = await getPrivateUser(onUser.id)
|
||||
if (!privateUser) return
|
||||
const id = crypto.randomUUID()
|
||||
const reason = 'new_endorsement'
|
||||
const { sendToBrowser, sendToMobile, sendToEmail } =
|
||||
getNotificationDestinationsForUser(privateUser, reason)
|
||||
const {sendToBrowser, sendToMobile, sendToEmail} = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
reason,
|
||||
)
|
||||
const notification: Notification = {
|
||||
id,
|
||||
userId: privateUser.id,
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
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'
|
||||
import {tryCatch} from 'common/util/try-catch'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {insert} from 'shared/supabase/utils'
|
||||
import {getUser} from 'shared/utils'
|
||||
|
||||
export const createCompatibilityQuestion: APIHandler<
|
||||
'create-compatibility-question'
|
||||
> = async ({ question, options }, auth) => {
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
|
||||
export const createCompatibilityQuestion: APIHandler<'create-compatibility-question'> = async (
|
||||
{question, options},
|
||||
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(
|
||||
const {data, error} = await tryCatch(
|
||||
insert(pg, 'compatibility_prompts', {
|
||||
creator_id: creator.id,
|
||||
question,
|
||||
answer_type: 'compatibility_multiple_choice',
|
||||
multiple_choice_options: options,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
if (error) throw new APIError(401, 'Error creating question')
|
||||
|
||||
return { question: data }
|
||||
return {question: data}
|
||||
}
|
||||
|
||||
49
backend/api/src/create-event.ts
Normal file
49
backend/api/src/create-event.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {APIError, APIHandler} from 'api/helpers/endpoint'
|
||||
import {tryCatch} from 'common/util/try-catch'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {insert} from 'shared/supabase/utils'
|
||||
|
||||
export const createEvent: APIHandler<'create-event'> = async (body, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
// Validate location
|
||||
if (body.locationType === 'in_person' && !body.locationAddress) {
|
||||
throw new APIError(400, 'In-person events require a location address')
|
||||
}
|
||||
if (body.locationType === 'online' && !body.locationUrl) {
|
||||
throw new APIError(400, 'Online events require a location URL')
|
||||
}
|
||||
|
||||
// Validate dates
|
||||
const startTime = new Date(body.eventStartTime)
|
||||
if (startTime < new Date()) {
|
||||
throw new APIError(400, 'Event start time must be in the future')
|
||||
}
|
||||
|
||||
if (body.eventEndTime) {
|
||||
const endTime = new Date(body.eventEndTime)
|
||||
if (endTime <= startTime) {
|
||||
throw new APIError(400, 'Event end time must be after start time')
|
||||
}
|
||||
}
|
||||
|
||||
const {data, error} = await tryCatch(
|
||||
insert(pg, 'events', {
|
||||
creator_id: auth.uid,
|
||||
title: body.title,
|
||||
description: body.description,
|
||||
location_type: body.locationType,
|
||||
location_address: body.locationAddress,
|
||||
location_url: body.locationUrl,
|
||||
event_start_time: body.eventStartTime,
|
||||
event_end_time: body.eventEndTime,
|
||||
max_participants: body.maxParticipants,
|
||||
}),
|
||||
)
|
||||
|
||||
if (error) {
|
||||
throw new APIError(500, 'Failed to create event: ' + error.message)
|
||||
}
|
||||
|
||||
return {success: true, event: data}
|
||||
}
|
||||
@@ -1,11 +1,32 @@
|
||||
import {createSupabaseDirectClient, SupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {ANDROID_APP_URL} from 'common/constants'
|
||||
import {Notification} from 'common/notifications'
|
||||
import {insertNotificationToSupabase} from 'shared/supabase/notifications'
|
||||
import {tryCatch} from "common/util/try-catch";
|
||||
import {Row} from "common/supabase/utils";
|
||||
import {Row} from 'common/supabase/utils'
|
||||
import {tryCatch} from 'common/util/try-catch'
|
||||
import {createSupabaseDirectClient, SupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {createBulkNotification, insertNotificationToSupabase} from 'shared/supabase/notifications'
|
||||
|
||||
export const createAndroidReleaseNotifications = async () => {
|
||||
const createdTime = Date.now()
|
||||
const id = `android-release-${createdTime}`
|
||||
const notification: Notification = {
|
||||
id,
|
||||
userId: 'todo',
|
||||
createdTime: createdTime,
|
||||
isSeen: false,
|
||||
sourceType: 'info',
|
||||
sourceUpdateType: 'created',
|
||||
sourceSlug: ANDROID_APP_URL,
|
||||
sourceUserAvatarUrl:
|
||||
'https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/misc%2Fcompass-192.png?alt=media&token=9fd251c5-fc43-4375-b629-1a8f4bbe8185',
|
||||
title: 'Android App Released on Google Play',
|
||||
sourceText:
|
||||
'The Compass Android app is now publicly available on Google Play! Download it today to stay connected on the go.',
|
||||
}
|
||||
return await createNotifications(notification)
|
||||
}
|
||||
|
||||
export const createAndroidTestNotifications = async () => {
|
||||
const createdTime = Date.now();
|
||||
const createdTime = Date.now()
|
||||
const id = `android-test-${createdTime}`
|
||||
const notification: Notification = {
|
||||
id,
|
||||
@@ -15,15 +36,17 @@ export const createAndroidTestNotifications = async () => {
|
||||
sourceType: 'info',
|
||||
sourceUpdateType: 'created',
|
||||
sourceSlug: '/contact',
|
||||
sourceUserAvatarUrl: 'https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/misc%2Fcompass-192.png?alt=media&token=9fd251c5-fc43-4375-b629-1a8f4bbe8185',
|
||||
sourceUserAvatarUrl:
|
||||
'https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/misc%2Fcompass-192.png?alt=media&token=9fd251c5-fc43-4375-b629-1a8f4bbe8185',
|
||||
title: 'Android App Ready for Review — Help Us Unlock the Google Play Release',
|
||||
sourceText: 'To release our app, Google requires a closed test with at least 12 testers for 14 days. Please share your Google Play–registered email address so we can add you as a tester and complete the review process.',
|
||||
sourceText:
|
||||
'To release our app, Google requires a closed test with at least 12 testers for 14 days. Please share your Google Play–registered email address so we can add you as a tester and complete the review process.',
|
||||
}
|
||||
return await createNotifications(notification)
|
||||
}
|
||||
|
||||
export const createShareNotifications = async () => {
|
||||
const createdTime = Date.now();
|
||||
const createdTime = Date.now()
|
||||
const id = `share-${createdTime}`
|
||||
const notification: Notification = {
|
||||
id,
|
||||
@@ -33,7 +56,8 @@ export const createShareNotifications = async () => {
|
||||
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',
|
||||
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.',
|
||||
}
|
||||
@@ -41,7 +65,7 @@ export const createShareNotifications = async () => {
|
||||
}
|
||||
|
||||
export const createVoteNotifications = async () => {
|
||||
const createdTime = Date.now();
|
||||
const createdTime = Date.now()
|
||||
const id = `vote-${createdTime}`
|
||||
const notification: Notification = {
|
||||
id,
|
||||
@@ -51,18 +75,17 @@ export const createVoteNotifications = async () => {
|
||||
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',
|
||||
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!',
|
||||
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')
|
||||
)
|
||||
const {data: users, error} = await tryCatch(pg.many<Row<'users'>>('select * from users'))
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching users', error)
|
||||
@@ -87,8 +110,59 @@ export const createNotifications = async (notification: Notification) => {
|
||||
}
|
||||
}
|
||||
|
||||
export const createNotification = async (user: Row<'users'>, notification: Notification, pg: SupabaseDirectClient) => {
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
* Send "Events now available" notification to all users
|
||||
* Uses the new template-based system for efficient bulk notifications
|
||||
*/
|
||||
export const createEventsAvailableNotifications = async () => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
// Fetch all users
|
||||
const {data: users, error} = await tryCatch(pg.many<Row<'users'>>('select id from users'))
|
||||
|
||||
if (error) {
|
||||
console.error('Error fetching users', error)
|
||||
return {success: false, error}
|
||||
}
|
||||
|
||||
if (!users || users.length === 0) {
|
||||
console.error('No users found')
|
||||
return {success: false, error: 'No users found'}
|
||||
}
|
||||
|
||||
const userIds = users.map((u) => u.id)
|
||||
|
||||
// Create template and bulk notifications using the new system
|
||||
const {templateId, count} = await createBulkNotification(
|
||||
{
|
||||
sourceType: 'info',
|
||||
title: 'New Events Page',
|
||||
sourceText:
|
||||
'You can now create and join events on Compass! Meet up with other members online or in person for workshops, social events, etc.',
|
||||
sourceSlug: '/events',
|
||||
sourceUserAvatarUrl:
|
||||
'https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/misc%2Fcompass-192.png?alt=media&token=9fd251c5-fc43-4375-b629-1a8f4bbe8185',
|
||||
sourceUpdateType: 'created',
|
||||
},
|
||||
userIds,
|
||||
pg,
|
||||
)
|
||||
|
||||
console.log(`Created events notification template ${templateId} for ${count} users`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
templateId,
|
||||
userCount: count,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { APIError, APIHandler } from 'api/helpers/endpoint'
|
||||
import { filterDefined } from 'common/util/array'
|
||||
import { uniq } from 'lodash'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { addUsersToPrivateMessageChannel } from 'api/helpers/private-messages'
|
||||
import { getPrivateUser, getUser } from 'shared/utils'
|
||||
import {APIError, APIHandler} from 'api/helpers/endpoint'
|
||||
import {addUsersToPrivateMessageChannel} from 'api/helpers/private-messages'
|
||||
import {filterDefined} from 'common/util/array'
|
||||
import * as admin from 'firebase-admin'
|
||||
import {uniq} from 'lodash'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {getPrivateUser, getUser} from 'shared/utils'
|
||||
|
||||
export const createPrivateUserMessageChannel: APIHandler<
|
||||
'create-private-user-message-channel'
|
||||
> = async (body, auth) => {
|
||||
// Do not use auth.creds.data as its info can be staled. It comes from a client token, which refreshes hourly or so
|
||||
const user = await admin.auth().getUser(auth.uid)
|
||||
// console.log(JSON.stringify(user, null, 2))
|
||||
if (!user?.emailVerified) {
|
||||
throw new APIError(403, 'You must verify your email to contact people.')
|
||||
}
|
||||
|
||||
const userIds = uniq(body.userIds.concat(auth.uid))
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
@@ -16,37 +24,33 @@ export const createPrivateUserMessageChannel: APIHandler<
|
||||
const creator = await getUser(creatorId)
|
||||
if (!creator) throw new APIError(401, 'Your account was not found')
|
||||
if (creator.isBannedFromPosting) throw new APIError(403, 'You are banned')
|
||||
const toPrivateUsers = filterDefined(
|
||||
await Promise.all(userIds.map((id) => getPrivateUser(id)))
|
||||
)
|
||||
const toPrivateUsers = filterDefined(await Promise.all(userIds.map((id) => getPrivateUser(id))))
|
||||
|
||||
if (toPrivateUsers.length !== userIds.length)
|
||||
throw new APIError(
|
||||
404,
|
||||
`Private user ${userIds.find(
|
||||
(uid) => !toPrivateUsers.map((p) => p.id).includes(uid)
|
||||
)} not found`
|
||||
(uid) => !toPrivateUsers.map((p: any) => p.id).includes(uid),
|
||||
)} not found`,
|
||||
)
|
||||
|
||||
if (
|
||||
toPrivateUsers.some((user) =>
|
||||
user.blockedUserIds.some((blockedId) => userIds.includes(blockedId))
|
||||
toPrivateUsers.some((user: any) =>
|
||||
user.blockedUserIds.some((blockedId: string) => userIds.includes(blockedId)),
|
||||
)
|
||||
) {
|
||||
throw new APIError(
|
||||
403,
|
||||
'One of the users has blocked another user in the list'
|
||||
)
|
||||
throw new APIError(403, 'One of the users has blocked another user in the list')
|
||||
}
|
||||
|
||||
const currentChannel = await pg.oneOrNone(
|
||||
`
|
||||
select channel_id from private_user_message_channel_members
|
||||
group by channel_id
|
||||
having array_agg(user_id::text) @> array[$1]::text[]
|
||||
and array_agg(user_id::text) <@ array[$1]::text[]
|
||||
`,
|
||||
[userIds]
|
||||
select channel_id
|
||||
from private_user_message_channel_members
|
||||
group by channel_id
|
||||
having array_agg(user_id::text) @> array [$1]::text[]
|
||||
and array_agg(user_id::text) <@ array [$1]::text[]
|
||||
`,
|
||||
[userIds],
|
||||
)
|
||||
if (currentChannel)
|
||||
return {
|
||||
@@ -55,17 +59,19 @@ export const createPrivateUserMessageChannel: APIHandler<
|
||||
}
|
||||
|
||||
const channel = await pg.one(
|
||||
`insert into private_user_message_channels default values returning id`
|
||||
`insert into private_user_message_channels default
|
||||
values
|
||||
returning id`,
|
||||
)
|
||||
|
||||
await pg.none(
|
||||
`insert into private_user_message_channel_members (channel_id, user_id, role, status)
|
||||
values ($1, $2, 'creator', 'joined')
|
||||
`,
|
||||
[channel.id, creatorId]
|
||||
values ($1, $2, 'creator', 'joined')
|
||||
`,
|
||||
[channel.id, creatorId],
|
||||
)
|
||||
|
||||
const memberIds = userIds.filter((id) => id !== creatorId)
|
||||
await addUsersToPrivateMessageChannel(memberIds, channel.id, pg)
|
||||
return { status: 'success', channelId: Number(channel.id) }
|
||||
return {status: 'success', channelId: Number(channel.id)}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import {APIError, APIHandler} from 'api/helpers/endpoint'
|
||||
import {getUser} from 'shared/utils'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {MAX_COMMENT_JSON_LENGTH} from 'api/create-comment'
|
||||
import {APIError, APIHandler} from 'api/helpers/endpoint'
|
||||
import {createPrivateUserMessageMain} from 'api/helpers/private-messages'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {getUser} from 'shared/utils'
|
||||
|
||||
export const createPrivateUserMessage: APIHandler<
|
||||
'create-private-user-message'
|
||||
> = async (body, auth) => {
|
||||
export const createPrivateUserMessage: APIHandler<'create-private-user-message'> = async (
|
||||
body,
|
||||
auth,
|
||||
) => {
|
||||
const {content, channelId} = body
|
||||
if (JSON.stringify(content).length > MAX_COMMENT_JSON_LENGTH) {
|
||||
throw new APIError(
|
||||
400,
|
||||
`Message JSON should be less than ${MAX_COMMENT_JSON_LENGTH}`
|
||||
)
|
||||
throw new APIError(400, `Message JSON should be less than ${MAX_COMMENT_JSON_LENGTH}`)
|
||||
}
|
||||
|
||||
const creator = await getUser(auth.uid)
|
||||
@@ -20,11 +18,5 @@ export const createPrivateUserMessage: APIHandler<
|
||||
if (creator.isBannedFromPosting) throw new APIError(403, 'You are banned')
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
return await createPrivateUserMessageMain(
|
||||
creator,
|
||||
channelId,
|
||||
content,
|
||||
pg,
|
||||
'private'
|
||||
)
|
||||
return await createPrivateUserMessageMain(creator, channelId, content, pg, 'private')
|
||||
}
|
||||
|
||||
@@ -1,40 +1,39 @@
|
||||
import { APIError, APIHandler } from 'api/helpers/endpoint'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { log, getUser } from 'shared/utils'
|
||||
import {APIError, APIHandler} from 'api/helpers/endpoint'
|
||||
import {sendDiscordMessage} from 'common/discord/core'
|
||||
import {jsonToMarkdown} from 'common/md'
|
||||
import {trimStrings} from 'common/parsing'
|
||||
import {HOUR_MS, MINUTE_MS, sleep} from 'common/util/time'
|
||||
import { removePinnedUrlFromPhotoUrls } from 'shared/profiles/parse-photos'
|
||||
import { track } from 'shared/analytics'
|
||||
import { updateUser } from 'shared/supabase/users'
|
||||
import { tryCatch } from 'common/util/try-catch'
|
||||
import { insert } from 'shared/supabase/utils'
|
||||
import {sendDiscordMessage} from "common/discord/core";
|
||||
import {jsonToMarkdown} from "common/md";
|
||||
import {tryCatch} from 'common/util/try-catch'
|
||||
import {track} from 'shared/analytics'
|
||||
import {removePinnedUrlFromPhotoUrls} from 'shared/profiles/parse-photos'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {updateUser} from 'shared/supabase/users'
|
||||
import {insert} from 'shared/supabase/utils'
|
||||
import {getUser, log} from 'shared/utils'
|
||||
|
||||
export const createProfile: APIHandler<'create-profile'> = async (body, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const { data: existingUser } = await tryCatch(
|
||||
pg.oneOrNone<{ id: string }>('select id from profiles where user_id = $1', [
|
||||
auth.uid,
|
||||
])
|
||||
const {data: existingUser} = await tryCatch(
|
||||
pg.oneOrNone<{id: string}>('select id from profiles where user_id = $1', [auth.uid]),
|
||||
)
|
||||
if (existingUser) {
|
||||
throw new APIError(400, 'User already exists')
|
||||
}
|
||||
|
||||
await removePinnedUrlFromPhotoUrls(body)
|
||||
trimStrings(body)
|
||||
|
||||
const user = await getUser(auth.uid)
|
||||
if (!user) throw new APIError(401, 'Your account was not found')
|
||||
if (user.createdTime > Date.now() - HOUR_MS) {
|
||||
// If they just signed up, set their avatar to be their pinned photo
|
||||
updateUser(pg, auth.uid, { avatarUrl: body.pinned_url })
|
||||
updateUser(pg, auth.uid, {avatarUrl: body.pinned_url})
|
||||
}
|
||||
|
||||
console.debug('body', body)
|
||||
|
||||
const { data, error } = await tryCatch(
|
||||
insert(pg, 'profiles', { user_id: auth.uid, ...body })
|
||||
)
|
||||
const {data, error} = await tryCatch(insert(pg, 'profiles', {user_id: auth.uid, ...body}))
|
||||
|
||||
if (error) {
|
||||
log.error('Error creating user: ' + error.message)
|
||||
@@ -64,10 +63,8 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
|
||||
console.error('Failed to send discord new profile', e)
|
||||
}
|
||||
try {
|
||||
const nProfiles = await pg.one<number>(
|
||||
`SELECT count(*) FROM profiles`,
|
||||
[],
|
||||
(r) => Number(r.count)
|
||||
const nProfiles = await pg.one<number>(`SELECT count(*) FROM profiles`, [], (r) =>
|
||||
Number(r.count),
|
||||
)
|
||||
|
||||
const isMilestone = (n: number) => {
|
||||
@@ -78,12 +75,8 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
|
||||
}
|
||||
console.debug(nProfiles, isMilestone(nProfiles))
|
||||
if (isMilestone(nProfiles)) {
|
||||
await sendDiscordMessage(
|
||||
`We just reached **${nProfiles}** total profiles! 🎉`,
|
||||
'general',
|
||||
)
|
||||
await sendDiscordMessage(`We just reached **${nProfiles}** total profiles! 🎉`, 'general')
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.error('Failed to send discord user milestone', e)
|
||||
}
|
||||
|
||||
@@ -1,32 +1,28 @@
|
||||
import * as admin from 'firebase-admin'
|
||||
import {PrivateUser} from 'common/user'
|
||||
import {randomString} from 'common/util/random'
|
||||
import {cleanDisplayName, cleanUsername} from 'common/util/clean-username'
|
||||
import {getIp, track} from 'shared/analytics'
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {getDefaultNotificationPreferences} from 'common/user-notification-preferences'
|
||||
import {removeUndefinedProps} from 'common/util/object'
|
||||
import {generateAvatarUrl} from 'shared/helpers/generate-and-update-avatar-urls'
|
||||
import {setLastOnlineTimeUser} from 'api/set-last-online-time'
|
||||
import {RESERVED_PATHS} from 'common/envs/constants'
|
||||
import {getUser, getUserByUsername, log} from 'shared/utils'
|
||||
import {IS_LOCAL} from 'common/hosting/constants'
|
||||
import {convertPrivateUser, convertUser} from 'common/supabase/users'
|
||||
import {PrivateUser} from 'common/user'
|
||||
import {getDefaultNotificationPreferences} from 'common/user-notification-preferences'
|
||||
import {cleanDisplayName, cleanUsername} from 'common/util/clean-username'
|
||||
import {removeUndefinedProps} from 'common/util/object'
|
||||
import {randomString} from 'common/util/random'
|
||||
import {sendWelcomeEmail} from 'email/functions/helpers'
|
||||
import * as admin from 'firebase-admin'
|
||||
import {getIp, track} from 'shared/analytics'
|
||||
import {getBucket} from 'shared/firebase-utils'
|
||||
import {generateAvatarUrl} from 'shared/helpers/generate-and-update-avatar-urls'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {insert} from 'shared/supabase/utils'
|
||||
import {convertPrivateUser, convertUser} from 'common/supabase/users'
|
||||
import {getBucket} from "shared/firebase-utils";
|
||||
import {sendWelcomeEmail} from "email/functions/helpers";
|
||||
import {setLastOnlineTimeUser} from "api/set-last-online-time";
|
||||
import {IS_LOCAL} from "common/hosting/constants";
|
||||
import {getUser, getUserByUsername, log} from 'shared/utils'
|
||||
|
||||
export const createUser: APIHandler<'create-user'> = async (
|
||||
props,
|
||||
auth,
|
||||
req
|
||||
) => {
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
|
||||
export const createUser: APIHandler<'create-user'> = async (props, auth, req) => {
|
||||
const {deviceToken: preDeviceToken} = props
|
||||
const firebaseUser = await admin.auth().getUser(auth.uid)
|
||||
|
||||
const testUserAKAEmailPasswordUser =
|
||||
firebaseUser.providerData[0].providerId === 'password'
|
||||
const testUserAKAEmailPasswordUser = firebaseUser.providerData[0].providerId === 'password'
|
||||
|
||||
// if (
|
||||
// testUserAKAEmailPasswordUser &&
|
||||
@@ -68,7 +64,7 @@ export const createUser: APIHandler<'create-user'> = async (
|
||||
from users
|
||||
where username ilike $1`,
|
||||
[username],
|
||||
(r) => r.count
|
||||
(r) => r.count,
|
||||
)
|
||||
const usernameExists = dupes > 0
|
||||
const isReservedName = RESERVED_PATHS.includes(username)
|
||||
@@ -83,14 +79,13 @@ export const createUser: APIHandler<'create-user'> = async (
|
||||
|
||||
// Check exact username to avoid problems with duplicate requests
|
||||
const sameNameUser = await getUserByUsername(username, tx)
|
||||
if (sameNameUser)
|
||||
throw new APIError(403, 'Username already taken', {username})
|
||||
if (sameNameUser) throw new APIError(403, 'Username already taken', {username})
|
||||
|
||||
const user = removeUndefinedProps({
|
||||
avatarUrl,
|
||||
isBannedFromPosting: Boolean(
|
||||
(deviceToken && bannedDeviceTokens.includes(deviceToken)) ||
|
||||
(ip && bannedIpAddresses.includes(ip))
|
||||
(ip && bannedIpAddresses.includes(ip)),
|
||||
),
|
||||
link: {},
|
||||
})
|
||||
|
||||
@@ -1,28 +1,30 @@
|
||||
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'
|
||||
import {tryCatch} from 'common/util/try-catch'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {insert} from 'shared/supabase/utils'
|
||||
import {getUser} from 'shared/utils'
|
||||
|
||||
export const createVote: APIHandler<
|
||||
'create-vote'
|
||||
> = async ({ title, description, isAnonymous }, auth) => {
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
|
||||
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(
|
||||
const {data, error} = await tryCatch(
|
||||
insert(pg, 'votes', {
|
||||
creator_id: creator.id,
|
||||
title,
|
||||
description,
|
||||
is_anonymous: isAnonymous,
|
||||
status: 'voting_open',
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
if (error) throw new APIError(401, 'Error creating question')
|
||||
|
||||
return { data }
|
||||
return {data}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import {APIHandler} from './helpers/endpoint'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
import {APIHandler} from './helpers/endpoint'
|
||||
|
||||
export const deleteBookmarkedSearch: APIHandler<'delete-bookmarked-search'> = async (
|
||||
props,
|
||||
auth
|
||||
auth,
|
||||
) => {
|
||||
const creator_id = auth.uid
|
||||
const {id} = props
|
||||
@@ -16,7 +17,7 @@ export const deleteBookmarkedSearch: APIHandler<'delete-bookmarked-search'> = as
|
||||
DELETE FROM bookmarked_searches
|
||||
WHERE id = $1 AND creator_id = $2
|
||||
`,
|
||||
[id, creator_id]
|
||||
[id, creator_id],
|
||||
)
|
||||
|
||||
return {}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import {APIHandler} from 'api/helpers/endpoint'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {APIError} from 'common/api/utils'
|
||||
import {recomputeCompatibilityScoresForUser} from 'shared/compatibility/compute-scores'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
export const deleteCompatibilityAnswer: APIHandler<'delete-compatibility-answer'> = async (
|
||||
{id}, auth) => {
|
||||
{id},
|
||||
auth,
|
||||
) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
// Verify user is the answer author
|
||||
@@ -13,7 +15,7 @@ export const deleteCompatibilityAnswer: APIHandler<'delete-compatibility-answer'
|
||||
FROM compatibility_answers
|
||||
WHERE id = $1
|
||||
AND creator_id = $2`,
|
||||
[id, auth.uid]
|
||||
[id, auth.uid],
|
||||
)
|
||||
|
||||
if (!item) {
|
||||
@@ -26,7 +28,7 @@ export const deleteCompatibilityAnswer: APIHandler<'delete-compatibility-answer'
|
||||
FROM compatibility_answers
|
||||
WHERE id = $1
|
||||
AND creator_id = $2`,
|
||||
[id, auth.uid]
|
||||
[id, auth.uid],
|
||||
)
|
||||
|
||||
const continuation = async () => {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import {getUser} from 'shared/utils'
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import * as admin from 'firebase-admin'
|
||||
import {deleteUserFiles} from 'shared/firebase-utils'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import * as admin from "firebase-admin";
|
||||
import {deleteUserFiles} from "shared/firebase-utils";
|
||||
import {getUser} from 'shared/utils'
|
||||
|
||||
export const deleteMe: APIHandler<'me/delete'> = async (_, auth) => {
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
|
||||
export const deleteMe: APIHandler<'me/delete'> = async ({reasonCategory, reasonDetails}, auth) => {
|
||||
const user = await getUser(auth.uid)
|
||||
if (!user) {
|
||||
throw new APIError(401, 'Your account was not found')
|
||||
@@ -14,8 +15,23 @@ export const deleteMe: APIHandler<'me/delete'> = async (_, auth) => {
|
||||
throw new APIError(400, 'Invalid user ID')
|
||||
}
|
||||
|
||||
// Remove user data from Supabase
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
// Store deletion reason before deleting the account
|
||||
try {
|
||||
await pg.none(
|
||||
`
|
||||
INSERT INTO deleted_users (username, reason_category, reason_details)
|
||||
VALUES ($1, $2, $3)
|
||||
`,
|
||||
[user.username, reasonCategory, reasonDetails],
|
||||
)
|
||||
} catch (e) {
|
||||
console.error('Error storing deletion reason:', e)
|
||||
// Don't fail the deletion if we can't store the reason
|
||||
}
|
||||
|
||||
// Remove user data from Supabase
|
||||
await pg.none('DELETE FROM users WHERE id = $1', [userId])
|
||||
// Should cascade delete in other tables
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {broadcastPrivateMessages} from 'api/helpers/private-messages'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {broadcastPrivateMessages} from "api/helpers/private-messages";
|
||||
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
|
||||
// const DELETED_MESSAGE_CONTENT: JSONContent = {
|
||||
// type: 'doc',
|
||||
@@ -26,7 +27,7 @@ export const deleteMessage: APIHandler<'delete-message'> = async ({messageId}, a
|
||||
FROM private_user_messages
|
||||
WHERE id = $1
|
||||
AND user_id = $2`,
|
||||
[messageId, auth.uid]
|
||||
[messageId, auth.uid],
|
||||
)
|
||||
|
||||
if (!message) {
|
||||
@@ -51,14 +52,12 @@ export const deleteMessage: APIHandler<'delete-message'> = async ({messageId}, a
|
||||
FROM private_user_messages
|
||||
WHERE id = $1
|
||||
AND user_id = $2`,
|
||||
[messageId, auth.uid]
|
||||
[messageId, auth.uid],
|
||||
)
|
||||
|
||||
void broadcastPrivateMessages(pg, message.channel_id, auth.uid)
|
||||
.catch((err) => {
|
||||
console.error('broadcastPrivateMessages failed', err)
|
||||
})
|
||||
void broadcastPrivateMessages(pg, message.channel_id, auth.uid).catch((err) => {
|
||||
console.error('broadcastPrivateMessages failed', err)
|
||||
})
|
||||
|
||||
return {success: true}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {broadcastPrivateMessages} from 'api/helpers/private-messages'
|
||||
import {encryptMessage} from 'shared/encryption'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {encryptMessage} from "shared/encryption";
|
||||
import {broadcastPrivateMessages} from "api/helpers/private-messages";
|
||||
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
|
||||
export const editMessage: APIHandler<'edit-message'> = async ({messageId, content}, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
@@ -15,7 +15,7 @@ export const editMessage: APIHandler<'edit-message'> = async ({messageId, conten
|
||||
AND user_id = $2
|
||||
-- AND created_time > NOW() - INTERVAL '1 day'
|
||||
AND deleted = FALSE`,
|
||||
[messageId, auth.uid]
|
||||
[messageId, auth.uid],
|
||||
)
|
||||
|
||||
if (!message) {
|
||||
@@ -32,13 +32,12 @@ export const editMessage: APIHandler<'edit-message'> = async ({messageId, conten
|
||||
is_edited = TRUE,
|
||||
edited_at = NOW()
|
||||
WHERE id = $4`,
|
||||
[ciphertext, iv, tag, messageId]
|
||||
[ciphertext, iv, tag, messageId],
|
||||
)
|
||||
|
||||
void broadcastPrivateMessages(pg, message.channel_id, auth.uid)
|
||||
.catch((err) => {
|
||||
console.error('broadcastPrivateMessages failed', err)
|
||||
})
|
||||
void broadcastPrivateMessages(pg, message.channel_id, auth.uid).catch((err) => {
|
||||
console.error('broadcastPrivateMessages failed', err)
|
||||
})
|
||||
|
||||
return {success: true}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,93 @@
|
||||
import { type APIHandler } from 'api/helpers/endpoint'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { Row } from 'common/supabase/utils'
|
||||
import {type APIHandler} from 'api/helpers/endpoint'
|
||||
import {Row} from 'common/supabase/utils'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
export function shuffle<T>(array: T[]): T[] {
|
||||
const arr = [...array]; // copy to avoid mutating the original
|
||||
const arr = [...array] // copy to avoid mutating the original
|
||||
for (let i = arr.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[arr[i], arr[j]] = [arr[j], arr[i]];
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
;[arr[i], arr[j]] = [arr[j], arr[i]]
|
||||
}
|
||||
return arr;
|
||||
return arr
|
||||
}
|
||||
|
||||
export const getCompatibilityQuestions: APIHandler<
|
||||
'get-compatibility-questions'
|
||||
> = async (_props, _auth) => {
|
||||
export const getCompatibilityQuestions: APIHandler<'get-compatibility-questions'> = async (
|
||||
props,
|
||||
_auth,
|
||||
) => {
|
||||
const {locale = 'en', keyword} = props
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
// Build query parameters
|
||||
const params: (string | number)[] = [locale]
|
||||
const paramIndex = 2
|
||||
|
||||
// Build keyword filter condition - search in question text and multiple_choice_options keys
|
||||
const keywordFilter = keyword
|
||||
? `AND (
|
||||
COALESCE(cpt.question, cp.question) ILIKE $${paramIndex}
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM jsonb_object_keys(
|
||||
COALESCE(cpt.multiple_choice_options, cp.multiple_choice_options)
|
||||
) AS option_key
|
||||
WHERE option_key ILIKE $${paramIndex}
|
||||
)
|
||||
)`
|
||||
: ''
|
||||
|
||||
if (keyword) {
|
||||
params.push(`%${keyword}%`)
|
||||
}
|
||||
|
||||
const questions = await pg.manyOrNone<
|
||||
Row<'compatibility_prompts'> & { answer_count: number; score: number }
|
||||
Row<'compatibility_prompts'> & {answer_count: number; score: number}
|
||||
>(
|
||||
`SELECT
|
||||
compatibility_prompts.*,
|
||||
COUNT(compatibility_answers.question_id) as answer_count,
|
||||
AVG(POWER(compatibility_answers.importance + 1 + CASE WHEN compatibility_answers.explanation IS NULL THEN 1 ELSE 0 END, 2)) as score
|
||||
FROM
|
||||
compatibility_prompts
|
||||
LEFT JOIN
|
||||
compatibility_answers ON compatibility_prompts.id = compatibility_answers.question_id
|
||||
WHERE
|
||||
compatibility_prompts.answer_type = 'compatibility_multiple_choice'
|
||||
GROUP BY
|
||||
compatibility_prompts.id
|
||||
ORDER BY
|
||||
compatibility_prompts.importance_score
|
||||
`
|
||||
SELECT cp.id,
|
||||
cp.answer_type,
|
||||
cp.importance_score,
|
||||
cp.created_time,
|
||||
cp.creator_id,
|
||||
cp.category,
|
||||
|
||||
-- locale-aware fields
|
||||
COALESCE(cpt.question, cp.question) AS question,
|
||||
COALESCE(cpt.multiple_choice_options, cp.multiple_choice_options) AS multiple_choice_options,
|
||||
|
||||
COUNT(ca.question_id) AS answer_count,
|
||||
AVG(
|
||||
POWER(
|
||||
ca.importance + 1 +
|
||||
CASE WHEN ca.explanation IS NULL THEN 1 ELSE 0 END,
|
||||
2
|
||||
)
|
||||
) AS score
|
||||
|
||||
FROM compatibility_prompts cp
|
||||
|
||||
LEFT JOIN compatibility_answers ca
|
||||
ON cp.id = ca.question_id
|
||||
|
||||
LEFT JOIN compatibility_prompts_translations cpt
|
||||
ON cp.id = cpt.question_id
|
||||
AND cpt.locale = $1
|
||||
AND $1 <> 'en'
|
||||
|
||||
WHERE cp.answer_type = 'compatibility_multiple_choice'
|
||||
${keywordFilter}
|
||||
|
||||
GROUP BY cp.id,
|
||||
cpt.question,
|
||||
cpt.multiple_choice_options
|
||||
|
||||
ORDER BY cp.importance_score
|
||||
`,
|
||||
[]
|
||||
params,
|
||||
)
|
||||
|
||||
// console.debug({questions})
|
||||
|
||||
// const questions = shuffle(dbQuestions)
|
||||
|
||||
// console.debug(
|
||||
|
||||
@@ -1,27 +1,19 @@
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { APIError, APIHandler } from './helpers/endpoint'
|
||||
import { PrivateUser } from 'common/user'
|
||||
import { Row } from 'common/supabase/utils'
|
||||
import { tryCatch } from 'common/util/try-catch'
|
||||
import {Row} from 'common/supabase/utils'
|
||||
import {PrivateUser} from 'common/user'
|
||||
import {tryCatch} from 'common/util/try-catch'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
export const getCurrentPrivateUser: APIHandler<'me/private'> = async (
|
||||
_,
|
||||
auth
|
||||
) => {
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
|
||||
export const getCurrentPrivateUser: APIHandler<'me/private'> = async (_, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const { data, error } = await tryCatch(
|
||||
pg.oneOrNone<Row<'private_users'>>(
|
||||
'select * from private_users where id = $1',
|
||||
[auth.uid]
|
||||
)
|
||||
const {data, error} = await tryCatch(
|
||||
pg.oneOrNone<Row<'private_users'>>('select * from private_users where id = $1', [auth.uid]),
|
||||
)
|
||||
|
||||
if (error) {
|
||||
throw new APIError(
|
||||
500,
|
||||
'Error fetching private user data: ' + error.message
|
||||
)
|
||||
throw new APIError(500, 'Error fetching private user data: ' + error.message)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
|
||||
89
backend/api/src/get-events.ts
Normal file
89
backend/api/src/get-events.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import {APIHandler} from 'api/helpers/endpoint'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
export const getEvents: APIHandler<'get-events'> = async () => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const events = await pg.manyOrNone<{
|
||||
id: string
|
||||
created_time: string
|
||||
creator_id: string
|
||||
title: string
|
||||
description: string | null
|
||||
location_type: 'in_person' | 'online'
|
||||
location_address: string | null
|
||||
location_url: string | null
|
||||
event_start_time: object
|
||||
event_end_time: object | null
|
||||
is_public: boolean
|
||||
max_participants: number | null
|
||||
status: 'active' | 'cancelled' | 'completed'
|
||||
}>(
|
||||
`SELECT *
|
||||
FROM events
|
||||
WHERE is_public = true
|
||||
AND status = 'active'
|
||||
ORDER BY event_start_time`,
|
||||
)
|
||||
|
||||
// Get participants for each event
|
||||
const eventIds = events.map((e) => e.id)
|
||||
const participants =
|
||||
eventIds.length > 0
|
||||
? await pg.manyOrNone<{
|
||||
event_id: string
|
||||
user_id: string
|
||||
status: 'going' | 'maybe' | 'not_going'
|
||||
}>(
|
||||
`SELECT event_id, user_id, status
|
||||
FROM events_participants
|
||||
WHERE event_id = ANY ($1)`,
|
||||
[eventIds],
|
||||
)
|
||||
: []
|
||||
|
||||
// Get creator info for each event
|
||||
const creatorIds = [...new Set(events.map((e) => e.creator_id))]
|
||||
const creators =
|
||||
creatorIds.length > 0
|
||||
? await pg.manyOrNone<{
|
||||
id: string
|
||||
name: string
|
||||
username: string
|
||||
avatar_url: string | null
|
||||
}>(
|
||||
`SELECT id, name, username, data ->> 'avatarUrl' as avatar_url
|
||||
FROM users
|
||||
WHERE id = ANY ($1)`,
|
||||
[creatorIds],
|
||||
)
|
||||
: []
|
||||
|
||||
const now = new Date()
|
||||
|
||||
const eventsWithDetails = events.map((event) => ({
|
||||
...event,
|
||||
participants: participants
|
||||
.filter((p) => p.event_id === event.id && p.status === 'going')
|
||||
.map((p) => p.user_id),
|
||||
maybe: participants
|
||||
.filter((p) => p.event_id === event.id && p.status === 'maybe')
|
||||
.map((p) => p.user_id),
|
||||
creator: creators.find((c) => c.id === event.creator_id),
|
||||
}))
|
||||
|
||||
const upcoming: typeof eventsWithDetails = []
|
||||
const past: typeof eventsWithDetails = []
|
||||
|
||||
for (const e of eventsWithDetails) {
|
||||
if ((e.event_end_time ?? e.event_start_time) > now) {
|
||||
upcoming.push(e)
|
||||
} else {
|
||||
past.push(e)
|
||||
}
|
||||
}
|
||||
|
||||
// console.debug({events, eventsWithDetails, upcoming, past, now})
|
||||
|
||||
return {upcoming, past}
|
||||
}
|
||||
38
backend/api/src/get-hidden-profiles.ts
Normal file
38
backend/api/src/get-hidden-profiles.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import {APIHandler} from 'api/helpers/endpoint'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
export const getHiddenProfiles: APIHandler<'get-hidden-profiles'> = async (
|
||||
{limit = 100, offset = 0},
|
||||
auth,
|
||||
) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
// Count total hidden for pagination info
|
||||
const countRes = await pg.one<{count: string}>(
|
||||
`select count(*)::text as count
|
||||
from hidden_profiles
|
||||
where hider_user_id = $1`,
|
||||
[auth.uid],
|
||||
)
|
||||
const count = Number(countRes.count) || 0
|
||||
|
||||
// Fetch hidden users joined with users table for display
|
||||
const rows = await pg.map(
|
||||
`select u.id, u.name, u.username, u.data ->> 'avatarUrl' as "avatarUrl", hp.created_time as "createdTime"
|
||||
from hidden_profiles hp
|
||||
join users u on u.id = hp.hidden_user_id
|
||||
where hp.hider_user_id = $1
|
||||
order by hp.created_time desc
|
||||
limit $2 offset $3`,
|
||||
[auth.uid, limit, offset],
|
||||
(r: any) => ({
|
||||
id: r.id as string,
|
||||
name: r.name as string,
|
||||
username: r.username as string,
|
||||
avatarUrl: r.avatarUrl as string | null | undefined,
|
||||
createdTime: r.createdTime as string | undefined,
|
||||
}),
|
||||
)
|
||||
|
||||
return {status: 'success', hidden: rows, count}
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
import { type APIHandler } from 'api/helpers/endpoint'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import {type APIHandler} from 'api/helpers/endpoint'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
export const getLikesAndShips: APIHandler<'get-likes-and-ships'> = async (
|
||||
props
|
||||
) => {
|
||||
const { userId } = props
|
||||
export const getLikesAndShips: APIHandler<'get-likes-and-ships'> = async (props) => {
|
||||
const {userId} = props
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
@@ -34,7 +32,7 @@ export const getLikesAndShipsMain = async (userId: string) => {
|
||||
(r) => ({
|
||||
user_id: r.target_id,
|
||||
created_time: new Date(r.created_time).getTime(),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const likesReceived = await pg.map<{
|
||||
@@ -56,7 +54,7 @@ export const getLikesAndShipsMain = async (userId: string) => {
|
||||
(r) => ({
|
||||
user_id: r.creator_id,
|
||||
created_time: new Date(r.created_time).getTime(),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const ships = await pg.map<{
|
||||
@@ -95,7 +93,7 @@ export const getLikesAndShipsMain = async (userId: string) => {
|
||||
(r) => ({
|
||||
...r,
|
||||
created_time: new Date(r.created_time).getTime(),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { type APIHandler } from './helpers/endpoint'
|
||||
import { getUser } from 'api/get-user'
|
||||
import {getUser} from 'api/get-user'
|
||||
|
||||
import {type APIHandler} from './helpers/endpoint'
|
||||
|
||||
export const getMe: APIHandler<'me'> = async (_, auth) => {
|
||||
return getUser({ id: auth.uid })
|
||||
return getUser({id: auth.uid})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
import {APIHandler} from './helpers/endpoint'
|
||||
import {createSupabaseDirectClient} from "shared/supabase/init";
|
||||
|
||||
export const getMessagesCount: APIHandler<'get-messages-count'> = async (_, _auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
@@ -8,10 +9,10 @@ export const getMessagesCount: APIHandler<'get-messages-count'> = async (_, _aut
|
||||
SELECT COUNT(*) AS count
|
||||
FROM private_user_messages;
|
||||
`,
|
||||
[]
|
||||
);
|
||||
const count = Number(result.count);
|
||||
console.debug('private_user_messages count:', count);
|
||||
[],
|
||||
)
|
||||
const count = Number(result.count)
|
||||
console.debug('private_user_messages count:', count)
|
||||
return {
|
||||
count: count,
|
||||
}
|
||||
|
||||
@@ -1,23 +1,49 @@
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { APIHandler } from 'api/helpers/endpoint'
|
||||
import { Notification } from 'common/notifications'
|
||||
import {APIHandler} from 'api/helpers/endpoint'
|
||||
import {Notification} from 'common/notifications'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
export const getNotifications: APIHandler<'get-notifications'> = async (
|
||||
props,
|
||||
auth
|
||||
) => {
|
||||
const { limit, after } = props
|
||||
export const getNotifications: APIHandler<'get-notifications'> = async (props, auth) => {
|
||||
const {limit, after} = props
|
||||
const pg = createSupabaseDirectClient()
|
||||
const query = `
|
||||
select data from user_notifications
|
||||
where user_id = $1
|
||||
and ($3 is null or (data->'createdTime')::bigint > $3)
|
||||
order by (data->'createdTime')::bigint desc
|
||||
select case
|
||||
when un.template_id is not null then
|
||||
jsonb_build_object(
|
||||
'id', un.notification_id,
|
||||
'userId', un.user_id,
|
||||
'templateId', un.template_id,
|
||||
'title', nt.title,
|
||||
'sourceType', nt.source_type,
|
||||
'sourceUpdateType', nt.source_update_type,
|
||||
'createdTime', nt.created_time,
|
||||
'isSeen', coalesce((un.data ->> 'isSeen')::boolean, false),
|
||||
'viewTime', (un.data ->> 'viewTime')::bigint,
|
||||
'sourceText', nt.source_text,
|
||||
'sourceSlug', nt.source_slug,
|
||||
'sourceUserAvatarUrl', nt.source_user_avatar_url,
|
||||
'data', nt.data
|
||||
)
|
||||
else
|
||||
un.data
|
||||
end as notification_data
|
||||
from user_notifications un
|
||||
left join notification_templates nt on un.template_id = nt.id
|
||||
where un.user_id = $1
|
||||
and ($3 is null or
|
||||
case
|
||||
when un.template_id is not null then nt.created_time > $3
|
||||
else (un.data ->> 'createdTime')::bigint > $3
|
||||
end
|
||||
)
|
||||
order by case
|
||||
when un.template_id is not null then nt.created_time
|
||||
else (un.data ->> 'createdTime')::bigint
|
||||
end desc
|
||||
limit $2
|
||||
`
|
||||
return await pg.map(
|
||||
query,
|
||||
[auth.uid, limit, after],
|
||||
(row) => row.data as Notification
|
||||
(row) => row.notification_data as Notification,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,20 +1,17 @@
|
||||
import {APIError, APIHandler} from 'api/helpers/endpoint'
|
||||
import {OPTION_TABLES} from 'common/profiles/constants'
|
||||
import {tryCatch} from 'common/util/try-catch'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {log} from 'shared/utils'
|
||||
import {tryCatch} from 'common/util/try-catch'
|
||||
import {OPTION_TABLES} from "common/profiles/constants";
|
||||
|
||||
export const getOptions: APIHandler<'get-options'> = async (
|
||||
{table},
|
||||
_auth
|
||||
) => {
|
||||
export const getOptions: APIHandler<'get-options'> = async ({table}, _auth) => {
|
||||
if (!OPTION_TABLES.includes(table)) throw new APIError(400, 'Invalid table')
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const result = await tryCatch(
|
||||
pg.manyOrNone<{ name: string }>(`SELECT interests.name
|
||||
FROM interests`)
|
||||
pg.manyOrNone<{name: string}>(`SELECT interests.name
|
||||
FROM interests`),
|
||||
)
|
||||
|
||||
if (result.error) {
|
||||
@@ -22,7 +19,6 @@ export const getOptions: APIHandler<'get-options'> = async (
|
||||
throw new APIError(500, 'Error getting profile options')
|
||||
}
|
||||
|
||||
const names = result.data.map(row => row.name)
|
||||
const names = result.data.map((row) => row.name)
|
||||
return {names}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {PrivateMessageChannel,} from 'common/supabase/private-messages'
|
||||
import {PrivateMessageChannel} from 'common/supabase/private-messages'
|
||||
import {tryCatch} from 'common/util/try-catch'
|
||||
import {groupBy, mapValues} from 'lodash'
|
||||
import {convertPrivateChatMessage} from "shared/supabase/messages";
|
||||
import {tryCatch} from "common/util/try-catch";
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {convertPrivateChatMessage} from 'shared/supabase/messages'
|
||||
|
||||
export const getChannelMemberships: APIHandler<
|
||||
'get-channel-memberships'
|
||||
> = async (props, auth) => {
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
|
||||
export const getChannelMemberships: APIHandler<'get-channel-memberships'> = async (props, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
const {channelId, lastUpdatedTime, createdTime, limit} = props
|
||||
|
||||
@@ -29,7 +28,7 @@ export const getChannelMemberships: APIHandler<
|
||||
limit $3
|
||||
`,
|
||||
[auth.uid, channelId, limit],
|
||||
convertRow
|
||||
convertRow,
|
||||
)
|
||||
} else {
|
||||
channels = await pg.map(
|
||||
@@ -59,11 +58,10 @@ export const getChannelMemberships: APIHandler<
|
||||
limit $3
|
||||
`,
|
||||
[auth.uid, createdTime ?? null, limit, lastUpdatedTime ?? null],
|
||||
convertRow
|
||||
convertRow,
|
||||
)
|
||||
}
|
||||
if (!channels || channels.length === 0)
|
||||
return {channels: [], memberIdsByChannelId: {}}
|
||||
if (!channels || channels.length === 0) return {channels: [], memberIdsByChannelId: {}}
|
||||
const channelIds = channels.map((c) => c.channel_id)
|
||||
|
||||
const members = await pg.map(
|
||||
@@ -77,12 +75,11 @@ export const getChannelMemberships: APIHandler<
|
||||
(r) => ({
|
||||
channel_id: r.channel_id as number,
|
||||
user_id: r.user_id as string,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
const memberIdsByChannelId = mapValues(
|
||||
groupBy(members, 'channel_id'),
|
||||
(members) => members.map((m) => m.user_id)
|
||||
const memberIdsByChannelId = mapValues(groupBy(members, 'channel_id'), (members) =>
|
||||
members.map((m) => m.user_id),
|
||||
)
|
||||
|
||||
return {
|
||||
@@ -93,23 +90,24 @@ export const getChannelMemberships: APIHandler<
|
||||
|
||||
export const getChannelMessagesEndpoint: APIHandler<'get-channel-messages'> = async (
|
||||
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;
|
||||
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 {data, error} = await tryCatch(pg.map(
|
||||
`select *, created_time as created_time_ts
|
||||
const {data, error} = await tryCatch(
|
||||
pg.map(
|
||||
`select *, created_time as created_time_ts
|
||||
from private_user_messages
|
||||
where channel_id = $1
|
||||
and exists (select 1
|
||||
@@ -119,11 +117,12 @@ export async function getChannelMessages(props: {
|
||||
and ($4 is null or id > $4)
|
||||
and not visibility = 'system_status'
|
||||
order by created_time desc
|
||||
limit $3
|
||||
${limit ? 'limit $3' : ''}
|
||||
`,
|
||||
[channelId, userId, limit, id],
|
||||
convertPrivateChatMessage
|
||||
))
|
||||
[channelId, userId, limit, id],
|
||||
convertPrivateChatMessage,
|
||||
),
|
||||
)
|
||||
if (error) {
|
||||
console.error(error)
|
||||
throw new APIError(401, 'Error getting messages')
|
||||
@@ -132,9 +131,7 @@ export async function getChannelMessages(props: {
|
||||
return data
|
||||
}
|
||||
|
||||
export const getLastSeenChannelTime: APIHandler<
|
||||
'get-channel-seen-time'
|
||||
> = async (props, auth) => {
|
||||
export const getLastSeenChannelTime: APIHandler<'get-channel-seen-time'> = async (props, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
const {channelIds} = props
|
||||
const unseens = await pg.map(
|
||||
@@ -145,20 +142,18 @@ export const getLastSeenChannelTime: APIHandler<
|
||||
order by channel_id, created_time desc
|
||||
`,
|
||||
[channelIds, auth.uid],
|
||||
(r) => [r.channel_id as number, r.created_time as string]
|
||||
(r) => [r.channel_id as number, r.created_time as string],
|
||||
)
|
||||
return unseens as [number, string][]
|
||||
}
|
||||
|
||||
export const setChannelLastSeenTime: APIHandler<
|
||||
'set-channel-seen-time'
|
||||
> = async (props, auth) => {
|
||||
export const setChannelLastSeenTime: APIHandler<'set-channel-seen-time'> = async (props, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
const {channelId} = props
|
||||
await pg.none(
|
||||
`insert into private_user_seen_message_channels (user_id, channel_id)
|
||||
values ($1, $2)
|
||||
`,
|
||||
[auth.uid, channelId]
|
||||
[auth.uid, channelId],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { type APIHandler } from 'api/helpers/endpoint'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { Row } from 'common/supabase/utils'
|
||||
import {type APIHandler} from 'api/helpers/endpoint'
|
||||
import {Row} from 'common/supabase/utils'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
export const getProfileAnswers: APIHandler<'get-profile-answers'> = async (
|
||||
props,
|
||||
_auth
|
||||
) => {
|
||||
const { userId } = props
|
||||
export const getProfileAnswers: APIHandler<'get-profile-answers'> = async (props, _auth) => {
|
||||
const {userId} = props
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const answers = await pg.manyOrNone<Row<'compatibility_answers'>>(
|
||||
@@ -15,7 +12,7 @@ export const getProfileAnswers: APIHandler<'get-profile-answers'> = async (
|
||||
creator_id = $1
|
||||
order by created_time desc
|
||||
`,
|
||||
[userId]
|
||||
[userId],
|
||||
)
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,50 +1,71 @@
|
||||
import {type APIHandler} from 'api/helpers/endpoint'
|
||||
import {OptionTableKey} from 'common/profiles/constants'
|
||||
import {compact} from 'lodash'
|
||||
import {convertRow} from 'shared/profiles/supabase'
|
||||
import {createSupabaseDirectClient, pgp} from 'shared/supabase/init'
|
||||
import {from, join, leftJoin, limit, orderBy, renderSql, select, where,} from 'shared/supabase/sql-builder'
|
||||
import {MIN_BIO_LENGTH} from "common/constants";
|
||||
import {compact} from "lodash";
|
||||
import {OptionTableKey} from "common/profiles/constants";
|
||||
import {
|
||||
from,
|
||||
join,
|
||||
leftJoin,
|
||||
limit,
|
||||
orderBy,
|
||||
renderSql,
|
||||
select,
|
||||
where,
|
||||
} from 'shared/supabase/sql-builder'
|
||||
|
||||
export type profileQueryType = {
|
||||
limit?: number | undefined,
|
||||
after?: string | undefined,
|
||||
// Search and filter parameters
|
||||
name?: string | undefined,
|
||||
genders?: string[] | undefined,
|
||||
education_levels?: string[] | undefined,
|
||||
pref_gender?: string[] | undefined,
|
||||
pref_age_min?: number | undefined,
|
||||
pref_age_max?: number | undefined,
|
||||
drinks_min?: number | undefined,
|
||||
drinks_max?: number | undefined,
|
||||
pref_relation_styles?: string[] | undefined,
|
||||
pref_romantic_styles?: string[] | undefined,
|
||||
diet?: string[] | undefined,
|
||||
political_beliefs?: string[] | undefined,
|
||||
mbti?: string[] | undefined,
|
||||
relationship_status?: string[] | undefined,
|
||||
languages?: string[] | undefined,
|
||||
religion?: string[] | undefined,
|
||||
wants_kids_strength?: number | undefined,
|
||||
has_kids?: number | undefined,
|
||||
is_smoker?: boolean | undefined,
|
||||
shortBio?: boolean | undefined,
|
||||
geodbCityIds?: string[] | undefined,
|
||||
lat?: number | undefined,
|
||||
lon?: number | undefined,
|
||||
radius?: number | undefined,
|
||||
compatibleWithUserId?: string | undefined,
|
||||
skipId?: string | undefined,
|
||||
orderBy?: string | undefined,
|
||||
lastModificationWithin?: string | undefined,
|
||||
limit?: number | undefined
|
||||
after?: string | undefined
|
||||
userId?: string | undefined
|
||||
name?: string | undefined
|
||||
genders?: string[] | undefined
|
||||
education_levels?: string[] | undefined
|
||||
pref_gender?: string[] | undefined
|
||||
pref_age_min?: number | undefined
|
||||
pref_age_max?: number | undefined
|
||||
drinks_min?: number | undefined
|
||||
drinks_max?: number | undefined
|
||||
big5_openness_min?: number | undefined
|
||||
big5_openness_max?: number | undefined
|
||||
big5_conscientiousness_min?: number | undefined
|
||||
big5_conscientiousness_max?: number | undefined
|
||||
big5_extraversion_min?: number | undefined
|
||||
big5_extraversion_max?: number | undefined
|
||||
big5_agreeableness_min?: number | undefined
|
||||
big5_agreeableness_max?: number | undefined
|
||||
big5_neuroticism_min?: number | undefined
|
||||
big5_neuroticism_max?: number | undefined
|
||||
pref_relation_styles?: string[] | undefined
|
||||
pref_romantic_styles?: string[] | undefined
|
||||
diet?: string[] | undefined
|
||||
political_beliefs?: string[] | undefined
|
||||
mbti?: string[] | undefined
|
||||
relationship_status?: string[] | undefined
|
||||
languages?: string[] | undefined
|
||||
religion?: string[] | undefined
|
||||
wants_kids_strength?: number | undefined
|
||||
has_kids?: number | undefined
|
||||
is_smoker?: boolean | undefined
|
||||
shortBio?: boolean | undefined
|
||||
geodbCityIds?: string[] | undefined
|
||||
lat?: number | undefined
|
||||
lon?: number | undefined
|
||||
radius?: number | undefined
|
||||
raised_in_lat?: number | undefined
|
||||
raised_in_lon?: number | undefined
|
||||
raised_in_radius?: number | undefined
|
||||
compatibleWithUserId?: string | undefined
|
||||
skipId?: string | undefined
|
||||
orderBy?: string | undefined
|
||||
lastModificationWithin?: string | undefined
|
||||
locale?: string | undefined
|
||||
} & {
|
||||
[K in OptionTableKey]?: string[] | undefined
|
||||
}
|
||||
|
||||
// const userActivityColumns = ['last_online_time']
|
||||
|
||||
|
||||
export const loadProfiles = async (props: profileQueryType) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
console.debug('loadProfiles', props)
|
||||
@@ -52,6 +73,7 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
limit: limitParam,
|
||||
after,
|
||||
name,
|
||||
userId,
|
||||
genders,
|
||||
education_levels,
|
||||
pref_gender,
|
||||
@@ -59,6 +81,16 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
pref_age_max,
|
||||
drinks_min,
|
||||
drinks_max,
|
||||
big5_openness_min,
|
||||
big5_openness_max,
|
||||
big5_conscientiousness_min,
|
||||
big5_conscientiousness_max,
|
||||
big5_extraversion_min,
|
||||
big5_extraversion_max,
|
||||
big5_agreeableness_min,
|
||||
big5_agreeableness_max,
|
||||
big5_neuroticism_min,
|
||||
big5_neuroticism_max,
|
||||
pref_relation_styles,
|
||||
pref_romantic_styles,
|
||||
diet,
|
||||
@@ -78,15 +110,25 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
lat,
|
||||
lon,
|
||||
radius,
|
||||
raised_in_lat,
|
||||
raised_in_lon,
|
||||
raised_in_radius,
|
||||
compatibleWithUserId,
|
||||
orderBy: orderByParam = 'created_time',
|
||||
lastModificationWithin,
|
||||
skipId,
|
||||
locale = 'en',
|
||||
} = props
|
||||
|
||||
const filterLocation = lat && lon && radius
|
||||
const filterRaisedInLocation = raised_in_lat && raised_in_lon && raised_in_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)
|
||||
|
||||
if (orderByParam === 'compatibility_score' && !compatibleWithUserId) {
|
||||
@@ -94,40 +136,50 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
throw Error('Incompatible with user ID')
|
||||
}
|
||||
|
||||
const tablePrefix = orderByParam === 'compatibility_score'
|
||||
? 'compatibility_scores'
|
||||
: orderByParam === 'last_online_time'
|
||||
? 'user_activity'
|
||||
: 'profiles'
|
||||
const tablePrefix =
|
||||
orderByParam === 'compatibility_score'
|
||||
? 'compatibility_scores'
|
||||
: orderByParam === 'last_online_time'
|
||||
? 'user_activity'
|
||||
: 'profiles'
|
||||
|
||||
const userActivityJoin = 'user_activity on user_activity.user_id = profiles.user_id'
|
||||
|
||||
const joinInterests = true // !!interests?.length
|
||||
const joinCauses = !!causes?.length
|
||||
const joinWork = true // !!work?.length
|
||||
|
||||
// Pre-aggregated interests per profile
|
||||
function getManyToManyJoin(label: OptionTableKey) {
|
||||
return `(
|
||||
SELECT
|
||||
profile_${label}.profile_id,
|
||||
ARRAY_AGG(${label}.name ORDER BY ${label}.name) AS ${label}
|
||||
ARRAY_AGG(${label}.id ORDER BY ${label}.id) AS ${label}
|
||||
FROM profile_${label}
|
||||
JOIN ${label} ON ${label}.id = profile_${label}.option_id
|
||||
GROUP BY profile_${label}.profile_id
|
||||
) profile_${label} ON profile_${label}.profile_id = profiles.id`
|
||||
}
|
||||
|
||||
const interestsJoin = getManyToManyJoin('interests')
|
||||
const causesJoin = getManyToManyJoin('causes')
|
||||
const workJoin = getManyToManyJoin('work')
|
||||
|
||||
const compatibilityScoreJoin = pgp.as.format(`compatibility_scores cs on (cs.user_id_1 = LEAST(profiles.user_id, $(compatibleWithUserId)) and cs.user_id_2 = GREATEST(profiles.user_id, $(compatibleWithUserId)))`, {compatibleWithUserId})
|
||||
const compatibilityScoreJoin = pgp.as.format(
|
||||
`compatibility_scores cs on (cs.user_id_1 = LEAST(profiles.user_id, $(compatibleWithUserId)) and cs.user_id_2 = GREATEST(profiles.user_id, $(compatibleWithUserId)))`,
|
||||
{compatibleWithUserId},
|
||||
)
|
||||
|
||||
const joins = [
|
||||
orderByParam === 'last_online_time' && leftJoin(userActivityJoin),
|
||||
orderByParam === 'compatibility_score' && compatibleWithUserId && join(compatibilityScoreJoin),
|
||||
interests && leftJoin(interestsJoin),
|
||||
causes && leftJoin(causesJoin),
|
||||
work && leftJoin(workJoin),
|
||||
joinInterests && leftJoin(interestsJoin),
|
||||
joinCauses && leftJoin(causesJoin),
|
||||
joinWork && leftJoin(workJoin),
|
||||
]
|
||||
|
||||
const _orderBy = orderByParam === 'compatibility_score' ? 'cs.score' : `${tablePrefix}.${orderByParam}`
|
||||
const _orderBy =
|
||||
orderByParam === 'compatibility_score' ? 'cs.score' : `${tablePrefix}.${orderByParam}`
|
||||
const afterFilter = renderSql(
|
||||
select(_orderBy),
|
||||
from('profiles'),
|
||||
@@ -146,115 +198,175 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
SELECT 1 FROM profile_${label}
|
||||
JOIN ${label} ON ${label}.id = profile_${label}.option_id
|
||||
WHERE profile_${label}.profile_id = profiles.id
|
||||
AND ${label}.name = ANY (ARRAY[$(values)])
|
||||
AND ${label}.id = ANY (ARRAY[$(values)])
|
||||
)`
|
||||
}
|
||||
|
||||
function getOptionClauseKeyword(label: OptionTableKey) {
|
||||
return `EXISTS (
|
||||
SELECT 1 FROM profile_${label}
|
||||
JOIN ${label} ON ${label}.id = profile_${label}.option_id
|
||||
LEFT JOIN ${label}_translations
|
||||
ON ${label}_translations.option_id = profile_${label}.option_id
|
||||
AND ${label}_translations.locale = $(locale)
|
||||
WHERE profile_${label}.profile_id = profiles.id
|
||||
AND lower(COALESCE(${label}_translations.name, ${label}.name)) ILIKE '%' || lower($(word)) || '%'
|
||||
)`
|
||||
}
|
||||
|
||||
const filters = [
|
||||
where('looking_for_matches = true'),
|
||||
where(`profiles.disabled != true`),
|
||||
// where(`pinned_url is not null and pinned_url != ''`),
|
||||
where(
|
||||
`(data->>'isBannedFromPosting' != 'true' or data->>'isBannedFromPosting' is null)`
|
||||
),
|
||||
where(`(data->>'isBannedFromPosting' != 'true' or data->>'isBannedFromPosting' is null)`),
|
||||
where(`data->>'userDeleted' != 'true' or data->>'userDeleted' is null`),
|
||||
|
||||
...keywords.map(word => where(
|
||||
`lower(users.name) ilike '%' || lower($(word)) || '%' or lower(bio::text) ilike '%' || lower($(word)) || '%' or bio_tsv @@ phraseto_tsquery('english', $(word))`,
|
||||
{word}
|
||||
)),
|
||||
...keywords.map((word) =>
|
||||
where(
|
||||
`lower(users.name) ilike '%' || lower($(word)) || '%'
|
||||
or lower(search_text) ilike '%' || lower($(word)) || '%'
|
||||
or search_tsv @@ phraseto_tsquery('english', $(word))
|
||||
OR ${getOptionClauseKeyword('interests')}
|
||||
OR ${getOptionClauseKeyword('causes')}
|
||||
OR ${getOptionClauseKeyword('work')}
|
||||
`,
|
||||
{word, locale},
|
||||
),
|
||||
),
|
||||
|
||||
genders?.length && where(`gender = ANY($(genders))`, {genders}),
|
||||
|
||||
education_levels?.length && where(`education_level = ANY($(education_levels))`, {education_levels}),
|
||||
education_levels?.length &&
|
||||
where(`education_level = ANY($(education_levels))`, {education_levels}),
|
||||
|
||||
mbti?.length && where(`mbti = ANY($(mbti))`, {mbti}),
|
||||
|
||||
pref_gender?.length &&
|
||||
where(`pref_gender is NULL or pref_gender = '{}' OR pref_gender && $(pref_gender)`, {pref_gender}),
|
||||
where(`pref_gender is NULL or pref_gender = '{}' OR pref_gender && $(pref_gender)`, {
|
||||
pref_gender,
|
||||
}),
|
||||
|
||||
pref_age_min &&
|
||||
where(`age >= $(pref_age_min) or age is null`, {pref_age_min}),
|
||||
pref_age_min && where(`age >= $(pref_age_min) or age is null`, {pref_age_min}),
|
||||
|
||||
pref_age_max &&
|
||||
where(`age <= $(pref_age_max) or age is null`, {pref_age_max}),
|
||||
pref_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}),
|
||||
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}),
|
||||
where(`drinks_per_month <= $(drinks_max) or drinks_per_month is null`, {drinks_max}),
|
||||
|
||||
big5_openness_min &&
|
||||
where(`big5_openness >= $(big5_openness_min) or big5_openness is null`, {big5_openness_min}),
|
||||
|
||||
big5_openness_max &&
|
||||
where(`big5_openness <= $(big5_openness_max) or big5_openness is null`, {big5_openness_max}),
|
||||
|
||||
big5_conscientiousness_min &&
|
||||
where(
|
||||
`big5_conscientiousness >= $(big5_conscientiousness_min) or big5_conscientiousness is null`,
|
||||
{big5_conscientiousness_min},
|
||||
),
|
||||
|
||||
big5_conscientiousness_max &&
|
||||
where(
|
||||
`big5_conscientiousness <= $(big5_conscientiousness_max) or big5_conscientiousness is null`,
|
||||
{big5_conscientiousness_max},
|
||||
),
|
||||
|
||||
big5_extraversion_min &&
|
||||
where(`big5_extraversion >= $(big5_extraversion_min) or big5_extraversion is null`, {
|
||||
big5_extraversion_min,
|
||||
}),
|
||||
|
||||
big5_extraversion_max &&
|
||||
where(`big5_extraversion <= $(big5_extraversion_max) or big5_extraversion is null`, {
|
||||
big5_extraversion_max,
|
||||
}),
|
||||
|
||||
big5_agreeableness_min &&
|
||||
where(`big5_agreeableness >= $(big5_agreeableness_min) or big5_agreeableness is null`, {
|
||||
big5_agreeableness_min,
|
||||
}),
|
||||
|
||||
big5_agreeableness_max &&
|
||||
where(`big5_agreeableness <= $(big5_agreeableness_max) or big5_agreeableness is null`, {
|
||||
big5_agreeableness_max,
|
||||
}),
|
||||
|
||||
big5_neuroticism_min &&
|
||||
where(`big5_neuroticism >= $(big5_neuroticism_min) or big5_neuroticism is null`, {
|
||||
big5_neuroticism_min,
|
||||
}),
|
||||
|
||||
big5_neuroticism_max &&
|
||||
where(`big5_neuroticism <= $(big5_neuroticism_max) or big5_neuroticism is null`, {
|
||||
big5_neuroticism_max,
|
||||
}),
|
||||
|
||||
pref_relation_styles?.length &&
|
||||
where(
|
||||
`pref_relation_styles IS NULL OR pref_relation_styles = '{}' OR pref_relation_styles && $(pref_relation_styles)`,
|
||||
{pref_relation_styles}
|
||||
),
|
||||
where(
|
||||
`pref_relation_styles IS NULL OR pref_relation_styles = '{}' OR 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}
|
||||
),
|
||||
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}
|
||||
),
|
||||
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}
|
||||
),
|
||||
where(
|
||||
`political_beliefs IS NULL OR political_beliefs = '{}' OR political_beliefs && $(political_beliefs)`,
|
||||
{political_beliefs},
|
||||
),
|
||||
|
||||
relationship_status?.length &&
|
||||
where(
|
||||
`relationship_status IS NULL OR relationship_status = '{}' OR relationship_status && $(relationship_status)`,
|
||||
{relationship_status}
|
||||
),
|
||||
where(
|
||||
`relationship_status IS NULL OR relationship_status = '{}' OR relationship_status && $(relationship_status)`,
|
||||
{relationship_status},
|
||||
),
|
||||
|
||||
languages?.length &&
|
||||
where(
|
||||
`languages && $(languages)`,
|
||||
{languages}
|
||||
),
|
||||
languages?.length && where(`languages && $(languages)`, {languages}),
|
||||
|
||||
religion?.length &&
|
||||
where(
|
||||
`religion IS NULL OR religion = '{}' OR religion && $(religion)`,
|
||||
{religion}
|
||||
),
|
||||
where(`religion IS NULL OR religion = '{}' OR religion && $(religion)`, {religion}),
|
||||
|
||||
interests?.length && where(getManyToManyClause('interests'), {values: interests}),
|
||||
interests?.length && where(getManyToManyClause('interests'), {values: interests.map(Number)}),
|
||||
|
||||
causes?.length && where(getManyToManyClause('causes'), {values: causes}),
|
||||
causes?.length && where(getManyToManyClause('causes'), {values: causes.map(Number)}),
|
||||
|
||||
work?.length && where(getManyToManyClause('work'), {values: work}),
|
||||
work?.length && where(getManyToManyClause('work'), {values: work.map(Number)}),
|
||||
|
||||
!!wants_kids_strength &&
|
||||
wants_kids_strength !== -1 &&
|
||||
where(
|
||||
'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 !== -1 &&
|
||||
where(
|
||||
'wants_kids_strength = -1 OR wants_kids_strength IS NULL OR ' +
|
||||
(wants_kids_strength >= 2
|
||||
? `wants_kids_strength >= $(wants_kids_strength)`
|
||||
: `wants_kids_strength <= $(wants_kids_strength)`),
|
||||
{wants_kids_strength},
|
||||
),
|
||||
|
||||
has_kids === 0 && where(`has_kids IS NULL OR has_kids = 0`),
|
||||
has_kids && has_kids > 0 && where(`has_kids > 0`),
|
||||
|
||||
is_smoker !== undefined && (
|
||||
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}
|
||||
)
|
||||
),
|
||||
`is_smoker = $(is_smoker)`,
|
||||
{is_smoker},
|
||||
),
|
||||
|
||||
geodbCityIds?.length &&
|
||||
where(`geodb_city_id = ANY($(geodbCityIds))`, {geodbCityIds}),
|
||||
geodbCityIds?.length && where(`geodb_city_id = ANY($(geodbCityIds))`, {geodbCityIds}),
|
||||
|
||||
// miles par degree of lat: earth's radius (3950 miles) * pi / 180 = 69.0
|
||||
filterLocation && where(`
|
||||
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)))))
|
||||
@@ -263,13 +375,51 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
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}),
|
||||
`,
|
||||
{target_lat: lat, target_lon: lon, radius},
|
||||
),
|
||||
|
||||
filterRaisedInLocation &&
|
||||
where(
|
||||
`
|
||||
raised_in_lat BETWEEN $(target_lat) - ($(radius) / 69.0)
|
||||
AND $(target_lat) + ($(radius) / 69.0)
|
||||
AND raised_in_lon BETWEEN $(target_lon) - ($(radius) / (69.0 * COS(RADIANS($(target_lat)))))
|
||||
AND $(target_lon) + ($(radius) / (69.0 * COS(RADIANS($(target_lat)))))
|
||||
AND SQRT(
|
||||
POWER(raised_in_lat - $(target_lat), 2)
|
||||
+ POWER((raised_in_lon - $(target_lon)) * COS(RADIANS($(target_lat))), 2)
|
||||
) <= $(radius) / 69.0
|
||||
`,
|
||||
{target_lat: raised_in_lat, target_lon: raised_in_lon, radius: raised_in_radius},
|
||||
),
|
||||
|
||||
skipId && where(`profiles.user_id != $(skipId)`, {skipId}),
|
||||
|
||||
!shortBio && where(`bio_length >= ${MIN_BIO_LENGTH}`, {MIN_BIO_LENGTH}),
|
||||
!shortBio &&
|
||||
where(
|
||||
`bio_length >= ${100}
|
||||
OR array_length(profile_work.work, 1) > 0
|
||||
OR array_length(profile_interests.interests, 1) > 0
|
||||
OR occupation_title IS NOT NULL
|
||||
`,
|
||||
),
|
||||
|
||||
lastModificationWithin && where(`last_modification_time >= NOW() - INTERVAL $(lastModificationWithin)`, {lastModificationWithin}),
|
||||
lastModificationWithin &&
|
||||
where(`last_modification_time >= NOW() - INTERVAL $(lastModificationWithin)`, {
|
||||
lastModificationWithin,
|
||||
}),
|
||||
|
||||
// Exclude profiles that the requester has chosen to hide
|
||||
userId &&
|
||||
where(
|
||||
`NOT EXISTS (
|
||||
SELECT 1 FROM hidden_profiles hp
|
||||
WHERE hp.hider_user_id = $(userId)
|
||||
AND hp.hidden_user_id = profiles.user_id
|
||||
)`,
|
||||
{userId},
|
||||
),
|
||||
]
|
||||
|
||||
let selectCols = 'profiles.*, users.name, users.username, users.data as user'
|
||||
@@ -278,9 +428,9 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
} else if (orderByParam === 'last_online_time') {
|
||||
selectCols += ', user_activity.last_online_time'
|
||||
}
|
||||
if (interests) selectCols += `, COALESCE(profile_interests.interests, '{}') AS interests`
|
||||
if (causes) selectCols += `, COALESCE(profile_causes.causes, '{}') AS causes`
|
||||
if (work) selectCols += `, COALESCE(profile_work.work, '{}') AS work`
|
||||
if (joinInterests) selectCols += `, COALESCE(profile_interests.interests, '{}') AS interests`
|
||||
if (joinCauses) selectCols += `, COALESCE(profile_causes.causes, '{}') AS causes`
|
||||
if (joinWork) selectCols += `, COALESCE(profile_work.work, '{}') AS work`
|
||||
|
||||
const query = renderSql(
|
||||
select(selectCols),
|
||||
@@ -297,11 +447,7 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
|
||||
// console.debug('profiles:', profiles)
|
||||
|
||||
const countQuery = renderSql(
|
||||
select(`count(*) as count`),
|
||||
...tableSelection,
|
||||
...filters,
|
||||
)
|
||||
const countQuery = renderSql(select(`count(*) as count`), ...tableSelection, ...filters)
|
||||
|
||||
const count = await pg.one<number>(countQuery, [], (r) => Number(r.count))
|
||||
|
||||
@@ -311,7 +457,7 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
export const getProfiles: APIHandler<'get-profiles'> = async (props, auth) => {
|
||||
try {
|
||||
if (!props.skipId) props.skipId = auth.uid
|
||||
const {profiles, count} = await loadProfiles(props)
|
||||
const {profiles, count} = await loadProfiles({...props, userId: auth.uid})
|
||||
return {status: 'success', profiles: profiles, count: count}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import {ENV_CONFIG} from 'common/envs/constants'
|
||||
import {sign} from 'jsonwebtoken'
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {ENV_CONFIG} from "common/envs/constants";
|
||||
|
||||
export const getSupabaseToken: APIHandler<'get-supabase-token'> = async (
|
||||
_,
|
||||
auth
|
||||
) => {
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
|
||||
export const getSupabaseToken: APIHandler<'get-supabase-token'> = async (_, auth) => {
|
||||
const jwtSecret = process.env.SUPABASE_JWT_SECRET
|
||||
if (jwtSecret == null) {
|
||||
throw new APIError(500, "No SUPABASE_JWT_SECRET; couldn't sign token.")
|
||||
|
||||
180
backend/api/src/get-user-data-export.ts
Normal file
180
backend/api/src/get-user-data-export.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import {Row} from 'common/supabase/utils'
|
||||
import {parseJsonContentToText} from 'common/util/parse'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {parseMessageObject} from 'shared/supabase/messages'
|
||||
|
||||
import {getLikesAndShipsMain} from './get-likes-and-ships'
|
||||
import {APIHandler} from './helpers/endpoint'
|
||||
|
||||
export const getUserDataExport: APIHandler<'me/data'> = async (_, auth) => {
|
||||
const userId = auth.uid
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const user = await pg.oneOrNone<Row<'users'>>('select * from users where id = $1', [userId])
|
||||
|
||||
const privateUser = await pg.oneOrNone<Row<'private_users'>>(
|
||||
'select * from private_users where id = $1',
|
||||
[userId],
|
||||
)
|
||||
|
||||
const profile = await pg.oneOrNone(
|
||||
`
|
||||
select profiles.*,
|
||||
users.name,
|
||||
users.username,
|
||||
users.data as "user",
|
||||
COALESCE(profile_interests.interests, '{}') as interests,
|
||||
COALESCE(profile_causes.causes, '{}') as causes,
|
||||
COALESCE(profile_work.work, '{}') as work
|
||||
from profiles
|
||||
join users on users.id = profiles.user_id
|
||||
left join (select pi.profile_id,
|
||||
array_agg(i.name order by i.id) as interests
|
||||
from profile_interests pi
|
||||
join interests i on i.id = pi.option_id
|
||||
group by pi.profile_id) as profile_interests on profile_interests.profile_id = profiles.id
|
||||
left join (select pc.profile_id,
|
||||
array_agg(c.name order by c.id) as causes
|
||||
from profile_causes pc
|
||||
join causes c on c.id = pc.option_id
|
||||
group by pc.profile_id) as profile_causes on profile_causes.profile_id = profiles.id
|
||||
left join (select pw.profile_id,
|
||||
array_agg(w.name order by w.id) as work
|
||||
from profile_work pw
|
||||
join work w on w.id = pw.option_id
|
||||
group by pw.profile_id) as profile_work on profile_work.profile_id = profiles.id
|
||||
where profiles.user_id = $1
|
||||
`,
|
||||
[userId],
|
||||
)
|
||||
|
||||
if (profile.bio) {
|
||||
profile.bio_clean = parseJsonContentToText(profile.bio).replace(/\n/g, ' ').trim()
|
||||
}
|
||||
|
||||
const compatibilityAnswers = await pg.manyOrNone(
|
||||
`
|
||||
select a.*,
|
||||
p.question,
|
||||
p.answer_type,
|
||||
p.multiple_choice_options,
|
||||
p.category,
|
||||
p.importance_score
|
||||
from compatibility_answers a
|
||||
join compatibility_prompts p
|
||||
on p.id = a.question_id
|
||||
where a.creator_id = $1
|
||||
order by a.created_time desc
|
||||
`,
|
||||
[userId],
|
||||
)
|
||||
|
||||
const userActivity = await pg.oneOrNone<Row<'user_activity'>>(
|
||||
'select * from user_activity where user_id = $1',
|
||||
[userId],
|
||||
)
|
||||
|
||||
const searchBookmarks = await pg.manyOrNone<Row<'bookmarked_searches'>>(
|
||||
'select * from bookmarked_searches where creator_id = $1 order by id desc',
|
||||
[userId],
|
||||
)
|
||||
|
||||
const hiddenProfiles = await pg.manyOrNone(
|
||||
`select hp.id, hp.hidden_user_id, hp.created_time, u.username
|
||||
from hidden_profiles hp
|
||||
join users u on u.id = hp.hidden_user_id
|
||||
where hp.hider_user_id = $1
|
||||
order by hp.id desc`,
|
||||
[userId],
|
||||
)
|
||||
|
||||
const messageChannelMemberships = await pg.manyOrNone<
|
||||
Row<'private_user_message_channel_members'>
|
||||
>('select * from private_user_message_channel_members where user_id = $1', [userId])
|
||||
|
||||
const channelIds = Array.from(new Set(messageChannelMemberships.map((m) => m.channel_id)))
|
||||
|
||||
const messageChannels = channelIds.length
|
||||
? await pg.manyOrNone<Row<'private_user_message_channels'>>(
|
||||
'select * from private_user_message_channels where id = any($1)',
|
||||
[channelIds],
|
||||
)
|
||||
: []
|
||||
|
||||
const messages = channelIds.length
|
||||
? await pg.manyOrNone<Row<'private_user_messages'>>(
|
||||
`select *
|
||||
from private_user_messages
|
||||
where channel_id = any ($1)
|
||||
order by created_time`,
|
||||
[channelIds],
|
||||
)
|
||||
: []
|
||||
for (const message of messages) parseMessageObject(message)
|
||||
|
||||
const membershipsWithUsernames = channelIds.length
|
||||
? await pg.manyOrNone(
|
||||
`
|
||||
select m.*,
|
||||
u.username
|
||||
from private_user_message_channel_members m
|
||||
join users u on u.id = m.user_id
|
||||
where m.channel_id = any ($1)
|
||||
and m.user_id != $2
|
||||
`,
|
||||
[channelIds, userId],
|
||||
)
|
||||
: []
|
||||
|
||||
const endorsements = await getLikesAndShipsMain(userId)
|
||||
|
||||
const accountMetadata = {
|
||||
// userData: (user as any)?.data ?? null,
|
||||
userActivity,
|
||||
}
|
||||
|
||||
const voteAnswers = await pg.manyOrNone(
|
||||
`
|
||||
select r.*,
|
||||
v.title,
|
||||
v.description,
|
||||
v.is_anonymous,
|
||||
v.status,
|
||||
v.created_time as vote_created_time
|
||||
from vote_results r
|
||||
join votes v on v.id = r.vote_id
|
||||
where r.user_id = $1
|
||||
order by v.created_time desc
|
||||
`,
|
||||
[userId],
|
||||
)
|
||||
|
||||
const reports = await pg.manyOrNone<Row<'reports'>>(
|
||||
'select * from reports where user_id = $1 order by created_time desc nulls last',
|
||||
[userId],
|
||||
)
|
||||
|
||||
const contactMessages = await pg.manyOrNone<Row<'contact'>>(
|
||||
'select * from contact where user_id = $1 order by created_time desc nulls last',
|
||||
[userId],
|
||||
)
|
||||
|
||||
return {
|
||||
user,
|
||||
privateUser,
|
||||
profile,
|
||||
compatibilityAnswers,
|
||||
voteAnswers,
|
||||
messages: {
|
||||
channels: messageChannels,
|
||||
memberships: membershipsWithUsernames,
|
||||
messages,
|
||||
},
|
||||
endorsements,
|
||||
searchBookmarks,
|
||||
hiddenProfiles,
|
||||
reports,
|
||||
contactMessages,
|
||||
accountMetadata,
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,15 @@
|
||||
import { toUserAPIResponse } from 'common/api/user-types'
|
||||
import { convertUser } from 'common/supabase/users'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { APIError } from 'common/api/utils'
|
||||
import {toUserAPIResponse} from 'common/api/user-types'
|
||||
import {APIError} from 'common/api/utils'
|
||||
import {convertUser} from 'common/supabase/users'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
export const getUser = async (props: { id: string } | { username: string }) => {
|
||||
export const getUser = async (props: {id: string} | {username: string}) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
const user = await pg.oneOrNone(
|
||||
`select * from users
|
||||
where ${'id' in props ? 'id' : 'username'} = $1`,
|
||||
['id' in props ? props.id : props.username],
|
||||
(r) => (r ? convertUser(r) : null)
|
||||
(r) => (r ? convertUser(r) : null),
|
||||
)
|
||||
if (!user) throw new APIError(404, 'User not found')
|
||||
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { type APIHandler } from 'api/helpers/endpoint'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import {type APIHandler} from 'api/helpers/endpoint'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
export const hasFreeLike: APIHandler<'has-free-like'> = async (
|
||||
_props,
|
||||
auth
|
||||
) => {
|
||||
export const hasFreeLike: APIHandler<'has-free-like'> = async (_props, auth) => {
|
||||
return {
|
||||
status: 'success',
|
||||
hasFreeLike: await getHasFreeLike(auth.uid),
|
||||
@@ -23,7 +20,7 @@ export const getHasFreeLike = async (userId: string) => {
|
||||
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')
|
||||
limit 1
|
||||
`,
|
||||
[userId]
|
||||
[userId],
|
||||
)
|
||||
return !likeGivenToday
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { APIHandler } from './helpers/endpoint'
|
||||
import {git} from './../metadata.json'
|
||||
import {version as pkgVersion} from './../package.json'
|
||||
import {APIHandler} from './helpers/endpoint'
|
||||
|
||||
export const health: APIHandler<'health'> = async (_, auth) => {
|
||||
return {
|
||||
message: 'Server is working.',
|
||||
uid: auth?.uid,
|
||||
git: git,
|
||||
version: pkgVersion,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import * as admin from 'firebase-admin'
|
||||
import {z} from 'zod'
|
||||
import {NextFunction, Request, Response} from 'express'
|
||||
|
||||
import {PrivateUser} from 'common/user'
|
||||
import {
|
||||
API,
|
||||
APIPath,
|
||||
APIResponseOptionalContinue,
|
||||
APISchema,
|
||||
ValidatedAPIParams,
|
||||
} from 'common/api/schema'
|
||||
import {APIError} from 'common/api/utils'
|
||||
import {API, APIPath, APIResponseOptionalContinue, APISchema, ValidatedAPIParams,} from 'common/api/schema'
|
||||
import {PrivateUser} from 'common/user'
|
||||
import {NextFunction, Request, Response} from 'express'
|
||||
import * as admin from 'firebase-admin'
|
||||
import {getPrivateUserByKey, log} from 'shared/utils'
|
||||
import {z} from 'zod'
|
||||
|
||||
export {APIError} from 'common/api/utils'
|
||||
|
||||
@@ -27,10 +32,10 @@ export {APIError} from 'common/api/utils'
|
||||
|
||||
export type AuthedUser = {
|
||||
uid: string
|
||||
creds: JwtCredentials | (KeyCredentials & { privateUser: PrivateUser })
|
||||
creds: JwtCredentials | (KeyCredentials & {privateUser: PrivateUser})
|
||||
}
|
||||
type JwtCredentials = { kind: 'jwt'; data: admin.auth.DecodedIdToken }
|
||||
type KeyCredentials = { kind: 'key'; data: string }
|
||||
type JwtCredentials = {kind: 'jwt'; data: admin.auth.DecodedIdToken}
|
||||
type KeyCredentials = {kind: 'key'; data: string}
|
||||
type Credentials = JwtCredentials | KeyCredentials
|
||||
|
||||
// export async function verifyIdToken(payload: string): Promise<DecodedIdToken> {
|
||||
@@ -76,8 +81,8 @@ export const parseCredentials = async (req: Request): Promise<Credentials> => {
|
||||
try {
|
||||
return {kind: 'jwt', data: await auth.verifyIdToken(payload)}
|
||||
} catch (err) {
|
||||
const raw = payload.split(".")[0];
|
||||
console.log("JWT header:", JSON.parse(Buffer.from(raw, "base64").toString()));
|
||||
const raw = payload.split('.')[0]
|
||||
console.log('JWT header:', JSON.parse(Buffer.from(raw, 'base64').toString()))
|
||||
// This is somewhat suspicious, so get it into the firebase console
|
||||
console.error('Error verifying Firebase JWT: ', err, scheme, payload)
|
||||
throw new APIError(500, 'Error validating token.')
|
||||
@@ -170,10 +175,8 @@ export const validate = <T extends z.ZodTypeAny>(schema: T, val: unknown) => {
|
||||
|
||||
export type APIHandler<N extends APIPath> = (
|
||||
props: ValidatedAPIParams<N>,
|
||||
auth: APISchema<N> extends { authed: true }
|
||||
? AuthedUser
|
||||
: AuthedUser | undefined,
|
||||
req: Request
|
||||
auth: APISchema<N> extends {authed: true} ? AuthedUser : AuthedUser | undefined,
|
||||
req: Request,
|
||||
) => Promise<APIResponseOptionalContinue<N>>
|
||||
|
||||
// Simple in-memory fixed-window rate limiter keyed by auth uid (or IP if unauthenticated)
|
||||
@@ -182,7 +185,7 @@ export type APIHandler<N extends APIPath> = (
|
||||
// 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()
|
||||
const __rateLimitState: Map<string, {windowStart: number; count: number}> = new Map()
|
||||
|
||||
function getRateLimitConfig() {
|
||||
const authed = Number(process.env.API_RATE_LIMIT_PER_MIN_AUTHED ?? 120)
|
||||
@@ -228,11 +231,13 @@ function checkRateLimit(name: string, req: Request, res: Response, auth?: Authed
|
||||
}
|
||||
}
|
||||
|
||||
export const typedEndpoint = <N extends APIPath>(
|
||||
name: N,
|
||||
handler: APIHandler<N>
|
||||
) => {
|
||||
const {props: propSchema, authed: authRequired, rateLimited = false, method} = API[name] as APISchema<N>
|
||||
export const typedEndpoint = <N extends APIPath>(name: N, handler: APIHandler<N>) => {
|
||||
const {
|
||||
props: propSchema,
|
||||
authed: authRequired,
|
||||
rateLimited = false,
|
||||
method,
|
||||
} = API[name] as APISchema<N>
|
||||
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
let authUser: AuthedUser | undefined = undefined
|
||||
@@ -260,16 +265,14 @@ export const typedEndpoint = <N extends APIPath>(
|
||||
const resultOptionalContinue = await handler(
|
||||
validate(propSchema, props),
|
||||
authUser as AuthedUser,
|
||||
req
|
||||
req,
|
||||
)
|
||||
|
||||
const hasContinue =
|
||||
resultOptionalContinue &&
|
||||
'continue' in resultOptionalContinue &&
|
||||
'result' in resultOptionalContinue
|
||||
const result = hasContinue
|
||||
? resultOptionalContinue.result
|
||||
: resultOptionalContinue
|
||||
const result = hasContinue ? resultOptionalContinue.result : resultOptionalContinue
|
||||
|
||||
if (!res.headersSent) {
|
||||
// Convert bigint to number, b/c JSON doesn't support bigint.
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
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 {ChatVisibility} from 'common/chat-message'
|
||||
import {Json} from 'common/supabase/schema'
|
||||
import {User} from 'common/user'
|
||||
import {parseJsonContentToText} from 'common/util/parse'
|
||||
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"
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import {sendNewMessageEmail} from 'email/functions/helpers'
|
||||
import * as admin from 'firebase-admin'
|
||||
import {TokenMessage} from "firebase-admin/lib/messaging/messaging-api";
|
||||
import {TokenMessage} from 'firebase-admin/lib/messaging/messaging-api'
|
||||
import {first} from 'lodash'
|
||||
import {track} from 'shared/analytics'
|
||||
import {encryptMessage} from 'shared/encryption'
|
||||
import {log} from 'shared/monitoring/log'
|
||||
import {SupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {getPrivateUser, getUser} from 'shared/utils'
|
||||
import {broadcast} from 'shared/websockets/server'
|
||||
import webPush from 'web-push'
|
||||
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(timezone)
|
||||
@@ -48,7 +48,7 @@ export const insertPrivateMessage = async (
|
||||
channelId: number,
|
||||
userId: string,
|
||||
visibility: ChatVisibility,
|
||||
pg: SupabaseDirectClient
|
||||
pg: SupabaseDirectClient,
|
||||
) => {
|
||||
const plaintext = JSON.stringify(content)
|
||||
const {ciphertext, iv, tag} = encryptMessage(plaintext)
|
||||
@@ -56,20 +56,20 @@ export const insertPrivateMessage = async (
|
||||
`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]
|
||||
[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]
|
||||
[lastMessage.created_time, channelId],
|
||||
)
|
||||
}
|
||||
|
||||
export const addUsersToPrivateMessageChannel = async (
|
||||
userIds: string[],
|
||||
channelId: number,
|
||||
pg: SupabaseDirectClient
|
||||
pg: SupabaseDirectClient,
|
||||
) => {
|
||||
await Promise.all(
|
||||
userIds.map((id) =>
|
||||
@@ -78,15 +78,15 @@ export const addUsersToPrivateMessageChannel = async (
|
||||
values ($1, $2, 'member', 'proposed')
|
||||
on conflict do nothing
|
||||
`,
|
||||
[channelId, id]
|
||||
)
|
||||
)
|
||||
[channelId, id],
|
||||
),
|
||||
),
|
||||
)
|
||||
await pg.none(
|
||||
`update private_user_message_channels
|
||||
set last_updated_time = now()
|
||||
where id = $1`,
|
||||
[channelId]
|
||||
[channelId],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -103,12 +103,12 @@ export async function broadcastPrivateMessages(
|
||||
and status != 'left'
|
||||
`,
|
||||
[channelId, userId],
|
||||
(r) => r.user_id
|
||||
(r) => r.user_id,
|
||||
)
|
||||
otherUserIds.concat(userId).forEach((otherUserId) => {
|
||||
broadcast(`private-user-messages/${otherUserId}`, {})
|
||||
})
|
||||
return otherUserIds;
|
||||
return otherUserIds
|
||||
}
|
||||
|
||||
export const createPrivateUserMessageMain = async (
|
||||
@@ -116,7 +116,7 @@ export const createPrivateUserMessageMain = async (
|
||||
channelId: number,
|
||||
content: JSONContent,
|
||||
pg: SupabaseDirectClient,
|
||||
visibility: ChatVisibility
|
||||
visibility: ChatVisibility,
|
||||
) => {
|
||||
log('createPrivateUserMessageMain', creator, channelId, content)
|
||||
|
||||
@@ -126,10 +126,9 @@ export const createPrivateUserMessageMain = async (
|
||||
from private_user_message_channel_members
|
||||
where channel_id = $1
|
||||
and user_id = $2`,
|
||||
[channelId, creator.id]
|
||||
[channelId, creator.id],
|
||||
)
|
||||
if (!authorized)
|
||||
throw new APIError(403, 'You are not authorized to post to this channel')
|
||||
if (!authorized) throw new APIError(403, 'You are not authorized to post to this channel')
|
||||
|
||||
await insertPrivateMessage(content, channelId, creator.id, visibility, pg)
|
||||
|
||||
@@ -138,13 +137,12 @@ export const createPrivateUserMessageMain = async (
|
||||
channel_id: channelId,
|
||||
user_id: creator.id,
|
||||
}
|
||||
const otherUserIds = await broadcastPrivateMessages(pg, channelId, creator.id);
|
||||
const otherUserIds = await broadcastPrivateMessages(pg, channelId, creator.id)
|
||||
|
||||
// Fire and forget safely
|
||||
void notifyOtherUserInChannelIfInactive(channelId, creator, content, pg)
|
||||
.catch((err) => {
|
||||
console.error('notifyOtherUserInChannelIfInactive failed', err)
|
||||
})
|
||||
void notifyOtherUserInChannelIfInactive(channelId, creator, content, pg).catch((err) => {
|
||||
console.error('notifyOtherUserInChannelIfInactive failed', err)
|
||||
})
|
||||
|
||||
track(creator.id, 'send private message', {
|
||||
channelId,
|
||||
@@ -158,16 +156,16 @@ const notifyOtherUserInChannelIfInactive = async (
|
||||
channelId: number,
|
||||
creator: User,
|
||||
content: JSONContent,
|
||||
pg: SupabaseDirectClient
|
||||
pg: SupabaseDirectClient,
|
||||
) => {
|
||||
const otherUserIds = await pg.manyOrNone<{ user_id: string }>(
|
||||
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]
|
||||
[channelId, creator.id],
|
||||
)
|
||||
// We're only sending notifs for 1:1 channels
|
||||
if (!otherUserIds || otherUserIds.length > 1) return
|
||||
@@ -191,10 +189,7 @@ const notifyOtherUserInChannelIfInactive = async (
|
||||
await sendWebNotifications(pg, receiverId, JSON.stringify(payload))
|
||||
await sendMobileNotifications(pg, receiverId, payload)
|
||||
|
||||
const startOfDay = dayjs()
|
||||
.tz('America/Los_Angeles')
|
||||
.startOf('day')
|
||||
.toISOString()
|
||||
const startOfDay = dayjs().tz('America/Los_Angeles').startOf('day').toISOString()
|
||||
const previousMessagesThisDayBetweenTheseUsers = await pg.one(
|
||||
`select count(*)
|
||||
from private_user_messages
|
||||
@@ -202,7 +197,7 @@ const notifyOtherUserInChannelIfInactive = async (
|
||||
and user_id = $2
|
||||
and created_time > $3
|
||||
`,
|
||||
[channelId, creator.id, startOfDay]
|
||||
[channelId, creator.id, startOfDay],
|
||||
)
|
||||
log('previous messages this day', previousMessagesThisDayBetweenTheseUsers)
|
||||
if (previousMessagesThisDayBetweenTheseUsers.count > 1) return
|
||||
@@ -210,27 +205,18 @@ const notifyOtherUserInChannelIfInactive = async (
|
||||
await createNewMessageNotification(creator, receiver, channelId)
|
||||
}
|
||||
|
||||
const createNewMessageNotification = async (
|
||||
fromUser: User,
|
||||
toUser: User,
|
||||
channelId: number,
|
||||
) => {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
async function sendWebNotifications(
|
||||
pg: SupabaseDirectClient,
|
||||
userId: string,
|
||||
payload: string,
|
||||
) {
|
||||
async function sendWebNotifications(pg: SupabaseDirectClient, userId: string, payload: string) {
|
||||
webPush.setVapidDetails(
|
||||
'mailto:hello@compassmeet.com',
|
||||
process.env.VAPID_PUBLIC_KEY!,
|
||||
process.env.VAPID_PRIVATE_KEY!
|
||||
process.env.VAPID_PRIVATE_KEY!,
|
||||
)
|
||||
// Retrieve subscription from the database
|
||||
const subscriptions = await getSubscriptionsFromDB(pg, userId)
|
||||
@@ -250,20 +236,18 @@ async function sendWebNotifications(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export async function getSubscriptionsFromDB(
|
||||
pg: SupabaseDirectClient,
|
||||
userId: string,
|
||||
) {
|
||||
export async function getSubscriptionsFromDB(pg: SupabaseDirectClient, userId: string) {
|
||||
try {
|
||||
const subscriptions = await pg.manyOrNone(`
|
||||
const subscriptions = await pg.manyOrNone(
|
||||
`
|
||||
select endpoint, keys
|
||||
from push_subscriptions
|
||||
where user_id = $1
|
||||
`, [userId]
|
||||
`,
|
||||
[userId],
|
||||
)
|
||||
|
||||
return subscriptions.map(sub => ({
|
||||
return subscriptions.map((sub) => ({
|
||||
endpoint: sub.endpoint,
|
||||
keys: sub.keys,
|
||||
}))
|
||||
@@ -273,35 +257,26 @@ export async function getSubscriptionsFromDB(
|
||||
}
|
||||
}
|
||||
|
||||
async function removeSubscription(
|
||||
pg: SupabaseDirectClient,
|
||||
endpoint: any,
|
||||
userId: string,
|
||||
) {
|
||||
async function removeSubscription(pg: SupabaseDirectClient, endpoint: any, userId: string) {
|
||||
await pg.none(
|
||||
`DELETE
|
||||
FROM push_subscriptions
|
||||
WHERE endpoint = $1
|
||||
AND user_id = $2`,
|
||||
[endpoint, userId]
|
||||
[endpoint, userId],
|
||||
)
|
||||
}
|
||||
|
||||
async function removeMobileSubscription(
|
||||
pg: SupabaseDirectClient,
|
||||
token: any,
|
||||
userId: string,
|
||||
) {
|
||||
async function removeMobileSubscription(pg: SupabaseDirectClient, token: any, userId: string) {
|
||||
await pg.none(
|
||||
`DELETE
|
||||
FROM push_subscriptions_mobile
|
||||
WHERE token = $1
|
||||
AND user_id = $2`,
|
||||
[token, userId]
|
||||
[token, userId],
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
async function sendMobileNotifications(
|
||||
pg: SupabaseDirectClient,
|
||||
userId: string,
|
||||
@@ -349,13 +324,15 @@ export async function sendPushToToken(
|
||||
} catch (err: unknown) {
|
||||
// Check if it's a Firebase Messaging error
|
||||
if (err instanceof Error && 'code' in err) {
|
||||
const firebaseError = err as { code: string; message: string }
|
||||
const firebaseError = err as {code: string; message: string}
|
||||
console.warn('Firebase error:', firebaseError.code, firebaseError.message)
|
||||
|
||||
// Handle specific error cases here if needed
|
||||
// For example, if token is no longer valid:
|
||||
if (firebaseError.code === 'messaging/registration-token-not-registered' ||
|
||||
firebaseError.code === 'messaging/invalid-argument') {
|
||||
if (
|
||||
firebaseError.code === 'messaging/registration-token-not-registered' ||
|
||||
firebaseError.code === 'messaging/invalid-argument'
|
||||
) {
|
||||
console.warn('Removing invalid FCM token')
|
||||
await removeMobileSubscription(pg, token, userId)
|
||||
}
|
||||
@@ -366,17 +343,15 @@ export async function sendPushToToken(
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
export async function getMobileSubscriptionsFromDB(
|
||||
pg: SupabaseDirectClient,
|
||||
userId: string,
|
||||
) {
|
||||
export async function getMobileSubscriptionsFromDB(pg: SupabaseDirectClient, userId: string) {
|
||||
try {
|
||||
const subscriptions = await pg.manyOrNone(`
|
||||
const subscriptions = await pg.manyOrNone(
|
||||
`
|
||||
select token
|
||||
from push_subscriptions_mobile
|
||||
where user_id = $1
|
||||
`, [userId]
|
||||
`,
|
||||
[userId],
|
||||
)
|
||||
|
||||
return subscriptions
|
||||
|
||||
@@ -1,35 +1,25 @@
|
||||
import { APIError, APIHandler } from 'api/helpers/endpoint'
|
||||
import { isAdminId } from 'common/envs/constants'
|
||||
import { convertComment } from 'common/supabase/comment'
|
||||
import { Row } from 'common/supabase/utils'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { broadcastUpdatedComment } from 'shared/websockets/helpers'
|
||||
import {APIError, APIHandler} from 'api/helpers/endpoint'
|
||||
import {isAdminId} from 'common/envs/constants'
|
||||
import {convertComment} from 'common/supabase/comment'
|
||||
import {Row} from 'common/supabase/utils'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {broadcastUpdatedComment} from 'shared/websockets/helpers'
|
||||
|
||||
export const hideComment: APIHandler<'hide-comment'> = async (
|
||||
{ commentId, hide },
|
||||
auth
|
||||
) => {
|
||||
export const hideComment: APIHandler<'hide-comment'> = async ({commentId, hide}, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
const comment = await pg.oneOrNone<Row<'profile_comments'>>(
|
||||
`select * from profile_comments where id = $1`,
|
||||
[commentId]
|
||||
[commentId],
|
||||
)
|
||||
if (!comment) {
|
||||
throw new APIError(404, 'Comment not found')
|
||||
}
|
||||
|
||||
if (
|
||||
!isAdminId(auth.uid) &&
|
||||
comment.user_id !== auth.uid &&
|
||||
comment.on_user_id !== auth.uid
|
||||
) {
|
||||
if (!isAdminId(auth.uid) && comment.user_id !== auth.uid && comment.on_user_id !== auth.uid) {
|
||||
throw new APIError(403, 'You are not allowed to hide this comment')
|
||||
}
|
||||
|
||||
await pg.none(`update profile_comments set hidden = $2 where id = $1`, [
|
||||
commentId,
|
||||
hide,
|
||||
])
|
||||
await pg.none(`update profile_comments set hidden = $2 where id = $1`, [commentId, hide])
|
||||
|
||||
broadcastUpdatedComment(convertComment(comment))
|
||||
}
|
||||
|
||||
21
backend/api/src/hide-profile.ts
Normal file
21
backend/api/src/hide-profile.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
|
||||
// Hide a profile for the requesting user by inserting a row into hidden_profiles.
|
||||
// Idempotent: if the pair already exists, succeed silently.
|
||||
export const hideProfile: APIHandler<'hide-profile'> = async ({hiddenUserId}, auth) => {
|
||||
if (auth.uid === hiddenUserId) throw new APIError(400, 'You cannot hide yourself')
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
// Insert idempotently: do nothing on conflict
|
||||
await pg.none(
|
||||
`insert into hidden_profiles (hider_user_id, hidden_user_id)
|
||||
values ($1, $2)
|
||||
on conflict (hider_user_id, hidden_user_id) do nothing`,
|
||||
[auth.uid, hiddenUserId],
|
||||
)
|
||||
|
||||
return {status: 'success'}
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
import { APIError, APIHandler } from 'api/helpers/endpoint'
|
||||
import { log, getUser } from 'shared/utils'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import {
|
||||
insertPrivateMessage,
|
||||
leaveChatContent,
|
||||
} from 'api/helpers/private-messages'
|
||||
import {APIError, APIHandler} from 'api/helpers/endpoint'
|
||||
import {insertPrivateMessage, leaveChatContent} from 'api/helpers/private-messages'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {getUser, log} from 'shared/utils'
|
||||
|
||||
export const leavePrivateUserMessageChannel: APIHandler<
|
||||
'leave-private-user-message-channel'
|
||||
> = async ({ channelId }, auth) => {
|
||||
> = async ({channelId}, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
const user = await getUser(auth.uid)
|
||||
if (!user) throw new APIError(401, 'Your account was not found')
|
||||
@@ -16,10 +13,9 @@ export const leavePrivateUserMessageChannel: APIHandler<
|
||||
const membershipStatus = await pg.oneOrNone(
|
||||
`select status from private_user_message_channel_members
|
||||
where channel_id = $1 and user_id = $2`,
|
||||
[channelId, auth.uid]
|
||||
[channelId, auth.uid],
|
||||
)
|
||||
if (!membershipStatus)
|
||||
throw new APIError(403, 'You are not authorized to post to this channel')
|
||||
if (!membershipStatus) throw new APIError(403, 'You are not authorized to post to this channel')
|
||||
log('membershipStatus: ' + membershipStatus)
|
||||
|
||||
// add message that the user left the channel
|
||||
@@ -29,15 +25,9 @@ export const leavePrivateUserMessageChannel: APIHandler<
|
||||
set status = 'left'
|
||||
where channel_id=$1 and user_id=$2;
|
||||
`,
|
||||
[channelId, auth.uid]
|
||||
[channelId, auth.uid],
|
||||
)
|
||||
|
||||
await insertPrivateMessage(
|
||||
leaveChatContent(user.name),
|
||||
channelId,
|
||||
auth.uid,
|
||||
'system_status',
|
||||
pg
|
||||
)
|
||||
return { status: 'success', channelId: Number(channelId) }
|
||||
await insertPrivateMessage(leaveChatContent(user.name), channelId, auth.uid, 'system_status', pg)
|
||||
return {status: 'success', channelId: Number(channelId)}
|
||||
}
|
||||
|
||||
@@ -1,42 +1,43 @@
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { APIError, APIHandler } from './helpers/endpoint'
|
||||
import { createProfileLikeNotification } from 'shared/create-profile-notification'
|
||||
import { getHasFreeLike } from './has-free-like'
|
||||
import { log } from 'shared/utils'
|
||||
import { tryCatch } from 'common/util/try-catch'
|
||||
import { Row } from 'common/supabase/utils'
|
||||
import {Row} from 'common/supabase/utils'
|
||||
import {tryCatch} from 'common/util/try-catch'
|
||||
import {createProfileLikeNotification} from 'shared/create-profile-notification'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {log} from 'shared/utils'
|
||||
|
||||
import {getHasFreeLike} from './has-free-like'
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
|
||||
export const likeProfile: APIHandler<'like-profile'> = async (props, auth) => {
|
||||
const { targetUserId, remove } = props
|
||||
const {targetUserId, remove} = props
|
||||
const creatorId = auth.uid
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
if (remove) {
|
||||
const { error } = await tryCatch(
|
||||
pg.none(
|
||||
'delete from profile_likes where creator_id = $1 and target_id = $2',
|
||||
[creatorId, targetUserId]
|
||||
)
|
||||
const {error} = await tryCatch(
|
||||
pg.none('delete from profile_likes where creator_id = $1 and target_id = $2', [
|
||||
creatorId,
|
||||
targetUserId,
|
||||
]),
|
||||
)
|
||||
|
||||
if (error) {
|
||||
throw new APIError(500, 'Failed to remove like: ' + error.message)
|
||||
}
|
||||
return { status: 'success' }
|
||||
return {status: 'success'}
|
||||
}
|
||||
|
||||
// Check if like already exists
|
||||
const { data: existing } = await tryCatch(
|
||||
const {data: existing} = await tryCatch(
|
||||
pg.oneOrNone<Row<'profile_likes'>>(
|
||||
'select * from profile_likes where creator_id = $1 and target_id = $2',
|
||||
[creatorId, targetUserId]
|
||||
)
|
||||
[creatorId, targetUserId],
|
||||
),
|
||||
)
|
||||
|
||||
if (existing) {
|
||||
log('Like already exists, do nothing')
|
||||
return { status: 'success' }
|
||||
return {status: 'success'}
|
||||
}
|
||||
|
||||
const hasFreeLike = await getHasFreeLike(creatorId)
|
||||
@@ -47,11 +48,11 @@ export const likeProfile: APIHandler<'like-profile'> = async (props, auth) => {
|
||||
}
|
||||
|
||||
// Insert the new like
|
||||
const { data, error } = await tryCatch(
|
||||
const {data, error} = await tryCatch(
|
||||
pg.one<Row<'profile_likes'>>(
|
||||
'insert into profile_likes (creator_id, target_id) values ($1, $2) returning *',
|
||||
[creatorId, targetUserId]
|
||||
)
|
||||
[creatorId, targetUserId],
|
||||
),
|
||||
)
|
||||
|
||||
if (error) {
|
||||
@@ -63,7 +64,7 @@ export const likeProfile: APIHandler<'like-profile'> = async (props, auth) => {
|
||||
}
|
||||
|
||||
return {
|
||||
result: { status: 'success' },
|
||||
result: {status: 'success'},
|
||||
continue: continuation,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { APIHandler } from './helpers/endpoint'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
export const markAllNotifsRead: APIHandler<'mark-all-notifs-read'> = async (
|
||||
_,
|
||||
auth
|
||||
) => {
|
||||
import {APIHandler} from './helpers/endpoint'
|
||||
|
||||
export const markAllNotifsRead: APIHandler<'mark-all-notifs-read'> = async (_, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
await pg.none(
|
||||
`update user_notifications
|
||||
SET data = jsonb_set(data, '{isSeen}', 'true'::jsonb)
|
||||
where user_id = $1
|
||||
and data->>'isSeen' = 'false'`,
|
||||
[auth.uid]
|
||||
[auth.uid],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,64 +1,135 @@
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #1e1e1e !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
.swagger-ui p,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
label,
|
||||
.btn,
|
||||
.parameter__name,
|
||||
.parameter__type,
|
||||
.parameter__in,
|
||||
.response-control-media-type__title,
|
||||
table thead tr td,
|
||||
table thead tr th,
|
||||
.tab li,
|
||||
.response-col_links,
|
||||
.opblock-summary-description {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
.swagger-ui .topbar, .opblock-body select, textarea {
|
||||
background-color: #2b2b2b !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
.swagger-ui .opblock {
|
||||
background-color: #2c2c2c !important;
|
||||
border-color: #fff !important;
|
||||
}
|
||||
.swagger-ui .opblock .opblock-summary-method {
|
||||
background-color: #1f1f1f !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.swagger-ui .opblock .opblock-section-header {
|
||||
background: #1f1f1f !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.swagger-ui .responses-wrapper {
|
||||
background-color: #1f1f1f !important;
|
||||
}
|
||||
.swagger-ui .response-col_status {
|
||||
color: #fff !important;
|
||||
}
|
||||
.swagger-ui .scheme-container {
|
||||
background-color: #1f1f1f !important;
|
||||
}
|
||||
.swagger-ui .modal-ux, input {
|
||||
background-color: #1f1f1f !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
.swagger-ui svg path {
|
||||
fill: white !important;
|
||||
}
|
||||
.swagger-ui .close-modal svg {
|
||||
color: #1e90ff !important;
|
||||
}
|
||||
a {
|
||||
color: #1e90ff !important;
|
||||
}
|
||||
body {
|
||||
background-color: #1e1e1e !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.swagger-ui p,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
label,
|
||||
.btn,
|
||||
.parameter__name,
|
||||
.parameter__type,
|
||||
.parameter__in,
|
||||
.response-control-media-type__title,
|
||||
table thead tr td,
|
||||
table thead tr th,
|
||||
.tab li,
|
||||
.response-col_links,
|
||||
.opblock-summary-description {
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.swagger-ui .topbar,
|
||||
.opblock-body select,
|
||||
textarea {
|
||||
background-color: #2b2b2b !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.swagger-ui .opblock {
|
||||
background-color: #2c2c2c !important;
|
||||
border-color: #fff !important;
|
||||
}
|
||||
|
||||
.swagger-ui .opblock .opblock-summary-method {
|
||||
background-color: #1f1f1f !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.swagger-ui .opblock .opblock-section-header {
|
||||
background: #1f1f1f !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.swagger-ui .responses-wrapper {
|
||||
background-color: #1f1f1f !important;
|
||||
}
|
||||
|
||||
.swagger-ui .response-col_status {
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.swagger-ui .scheme-container {
|
||||
background-color: #1f1f1f !important;
|
||||
}
|
||||
|
||||
.swagger-ui .modal-ux,
|
||||
input {
|
||||
background-color: #1f1f1f !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
|
||||
.swagger-ui svg path {
|
||||
fill: white !important;
|
||||
}
|
||||
|
||||
.swagger-ui .close-modal svg {
|
||||
color: #1e90ff !important;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #1e90ff !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Increase font sizes on mobile for better readability */
|
||||
/* Still not working though */
|
||||
@media (max-width: 640px) {
|
||||
html,
|
||||
body,
|
||||
.swagger-ui {
|
||||
font-size: 32px !important;
|
||||
line-height: 1.5 !important;
|
||||
}
|
||||
|
||||
.swagger-ui {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
/* Common text elements */
|
||||
.swagger-ui p,
|
||||
.swagger-ui label,
|
||||
.swagger-ui .btn,
|
||||
.swagger-ui .parameter__name,
|
||||
.swagger-ui .parameter__type,
|
||||
.swagger-ui .parameter__in,
|
||||
.swagger-ui .response-control-media-type__title,
|
||||
.swagger-ui table thead tr td,
|
||||
.swagger-ui table thead tr th,
|
||||
.swagger-ui table tbody tr td,
|
||||
.swagger-ui .tab li,
|
||||
.swagger-ui .response-col_links,
|
||||
.swagger-ui .opblock-summary-path,
|
||||
.swagger-ui .opblock-summary-description {
|
||||
font-size: 32px !important;
|
||||
}
|
||||
|
||||
/* Headings scale */
|
||||
.swagger-ui h1 {
|
||||
font-size: 1.75rem !important;
|
||||
}
|
||||
|
||||
.swagger-ui h2 {
|
||||
font-size: 1.5rem !important;
|
||||
}
|
||||
|
||||
.swagger-ui h3 {
|
||||
font-size: 1.25rem !important;
|
||||
}
|
||||
|
||||
.swagger-ui h4 {
|
||||
font-size: 1.125rem !important;
|
||||
}
|
||||
|
||||
.swagger-ui h5,
|
||||
.swagger-ui h6 {
|
||||
font-size: 1rem !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {broadcastPrivateMessages} from 'api/helpers/private-messages'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {broadcastPrivateMessages} from "api/helpers/private-messages";
|
||||
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
|
||||
export const reactToMessage: APIHandler<'react-to-message'> = async ({messageId, reaction, toDelete}, auth) => {
|
||||
export const reactToMessage: APIHandler<'react-to-message'> = async (
|
||||
{messageId, reaction, toDelete},
|
||||
auth,
|
||||
) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
// Verify user is a member of the channel
|
||||
@@ -13,7 +16,7 @@ export const reactToMessage: APIHandler<'react-to-message'> = async ({messageId,
|
||||
JOIN private_user_messages msg ON msg.channel_id = m.channel_id
|
||||
WHERE m.user_id = $1
|
||||
AND msg.id = $2`,
|
||||
[auth.uid, messageId]
|
||||
[auth.uid, messageId],
|
||||
)
|
||||
|
||||
if (!message) {
|
||||
@@ -27,7 +30,7 @@ export const reactToMessage: APIHandler<'react-to-message'> = async ({messageId,
|
||||
SET reactions = reactions - $1
|
||||
WHERE id = $2
|
||||
AND reactions -> $1 ? $3`,
|
||||
[reaction, messageId, auth.uid]
|
||||
[reaction, messageId, auth.uid],
|
||||
)
|
||||
} else {
|
||||
// Toggle reaction
|
||||
@@ -47,14 +50,13 @@ export const reactToMessage: APIHandler<'react-to-message'> = async ({messageId,
|
||||
)
|
||||
END
|
||||
WHERE id = $3`,
|
||||
[reaction, auth.uid, messageId]
|
||||
[reaction, auth.uid, messageId],
|
||||
)
|
||||
}
|
||||
|
||||
void broadcastPrivateMessages(pg, message.channel_id, auth.uid)
|
||||
.catch((err) => {
|
||||
console.error('broadcastPrivateMessages failed', err)
|
||||
})
|
||||
void broadcastPrivateMessages(pg, message.channel_id, auth.uid).catch((err) => {
|
||||
console.error('broadcastPrivateMessages failed', err)
|
||||
})
|
||||
|
||||
return {success: true}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
import { APIError } from 'api/helpers/endpoint'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { type APIHandler } from 'api/helpers/endpoint'
|
||||
import { isAdminId } from 'common/envs/constants'
|
||||
import { log } from 'shared/utils'
|
||||
import { tryCatch } from 'common/util/try-catch'
|
||||
import {APIError, type APIHandler} from 'api/helpers/endpoint'
|
||||
import {isAdminId} from 'common/envs/constants'
|
||||
import {tryCatch} from 'common/util/try-catch'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {log} from 'shared/utils'
|
||||
|
||||
export const removePinnedPhoto: APIHandler<'remove-pinned-photo'> = async (
|
||||
body: { userId: string },
|
||||
auth
|
||||
body: {userId: string},
|
||||
auth,
|
||||
) => {
|
||||
const { userId } = body
|
||||
log('remove pinned url', { userId })
|
||||
const {userId} = body
|
||||
log('remove pinned url', {userId})
|
||||
|
||||
if (!isAdminId(auth.uid))
|
||||
throw new APIError(403, 'Only admins can remove pinned photo')
|
||||
if (!isAdminId(auth.uid)) throw new APIError(403, 'Only admins can remove pinned photo')
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
const { error } = await tryCatch(
|
||||
pg.none('update profiles set pinned_url = null where user_id = $1', [userId])
|
||||
const {error} = await tryCatch(
|
||||
pg.none('update profiles set pinned_url = null where user_id = $1', [userId]),
|
||||
)
|
||||
|
||||
if (error) {
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {sendDiscordMessage} from 'common/discord/core'
|
||||
import {DOMAIN} from 'common/envs/constants'
|
||||
import {Row} from 'common/supabase/utils'
|
||||
import {tryCatch} from 'common/util/try-catch'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {insert} from 'shared/supabase/utils'
|
||||
import {sendDiscordMessage} from "common/discord/core";
|
||||
import {Row} from "common/supabase/utils";
|
||||
import {DOMAIN} from "common/envs/constants";
|
||||
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
|
||||
// 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
|
||||
export const report: APIHandler<'report'> = async (body, auth) => {
|
||||
const {
|
||||
contentOwnerId,
|
||||
contentType,
|
||||
contentId,
|
||||
description,
|
||||
parentId,
|
||||
parentType,
|
||||
} = body
|
||||
const {contentOwnerId, contentType, contentId, description, parentId, parentType} = body
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
@@ -29,7 +23,7 @@ export const report: APIHandler<'report'> = async (body, auth) => {
|
||||
description,
|
||||
parent_id: parentId,
|
||||
parent_type: parentType,
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
if (result.error) {
|
||||
@@ -39,14 +33,14 @@ export const report: APIHandler<'report'> = async (body, auth) => {
|
||||
const continuation = async () => {
|
||||
try {
|
||||
const {data: reporter, error} = await tryCatch(
|
||||
pg.oneOrNone<Row<'users'>>('select * from users where id = $1', [auth.uid])
|
||||
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])
|
||||
pg.oneOrNone<Row<'users'>>('select * from users where id = $1', [contentOwnerId]),
|
||||
)
|
||||
if (userError) {
|
||||
console.error('Failed to get reported user for report', userError)
|
||||
|
||||
83
backend/api/src/rsvp-event.ts
Normal file
83
backend/api/src/rsvp-event.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import {APIError, APIHandler} from 'api/helpers/endpoint'
|
||||
import {tryCatch} from 'common/util/try-catch'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {insert, update} from 'shared/supabase/utils'
|
||||
|
||||
export const rsvpEvent: APIHandler<'rsvp-event'> = async (body, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
// Check if event exists and is active
|
||||
const event = await pg.oneOrNone<{
|
||||
id: string
|
||||
status: string
|
||||
max_participants: number | null
|
||||
}>(
|
||||
`SELECT id, status, max_participants
|
||||
FROM events
|
||||
WHERE id = $1`,
|
||||
[body.eventId],
|
||||
)
|
||||
|
||||
if (!event) {
|
||||
throw new APIError(404, 'Event not found')
|
||||
}
|
||||
|
||||
if (event.status !== 'active') {
|
||||
throw new APIError(400, 'Cannot RSVP to a cancelled or completed event')
|
||||
}
|
||||
|
||||
// Check if already RSVPed
|
||||
const existingRsvp = await pg.oneOrNone<{
|
||||
id: string
|
||||
}>(
|
||||
`SELECT id
|
||||
FROM events_participants
|
||||
WHERE event_id = $1
|
||||
AND user_id = $2`,
|
||||
[body.eventId, auth.uid],
|
||||
)
|
||||
|
||||
if (existingRsvp) {
|
||||
// Update existing RSVP
|
||||
const {error} = await tryCatch(
|
||||
update(pg, 'events_participants', 'id', {
|
||||
status: body.status,
|
||||
id: existingRsvp.id,
|
||||
}),
|
||||
)
|
||||
|
||||
if (error) {
|
||||
throw new APIError(500, 'Failed to update RSVP: ' + error.message)
|
||||
}
|
||||
} else {
|
||||
// Check max participants limit
|
||||
if (event.max_participants && body.status === 'going') {
|
||||
const count = await pg.one<{count: number}>(
|
||||
`SELECT COUNT(*)
|
||||
FROM events_participants
|
||||
WHERE event_id = $1
|
||||
AND status = 'going'`,
|
||||
[body.eventId],
|
||||
)
|
||||
|
||||
if (Number(count.count) >= event.max_participants) {
|
||||
throw new APIError(400, 'Event is at maximum capacity')
|
||||
}
|
||||
}
|
||||
|
||||
// Create new RSVP
|
||||
const {error} = await tryCatch(
|
||||
insert(pg, 'events_participants', {
|
||||
event_id: body.eventId,
|
||||
user_id: auth.uid,
|
||||
status: body.status,
|
||||
}),
|
||||
)
|
||||
|
||||
if (error) {
|
||||
throw new APIError(500, 'Failed to RSVP: ' + error.message)
|
||||
}
|
||||
}
|
||||
|
||||
return {success: true}
|
||||
}
|
||||
@@ -1,7 +1,11 @@
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
export const saveSubscriptionMobile: APIHandler<'save-subscription-mobile'> = async (body, auth) => {
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
|
||||
export const saveSubscriptionMobile: APIHandler<'save-subscription-mobile'> = async (
|
||||
body,
|
||||
auth,
|
||||
) => {
|
||||
const {token} = body
|
||||
|
||||
if (!token) {
|
||||
@@ -12,17 +16,18 @@ export const saveSubscriptionMobile: APIHandler<'save-subscription-mobile'> = as
|
||||
|
||||
try {
|
||||
const pg = createSupabaseDirectClient()
|
||||
await pg.none(`
|
||||
await pg.none(
|
||||
`
|
||||
insert into push_subscriptions_mobile(token, platform, user_id)
|
||||
values ($1, $2, $3)
|
||||
on conflict(token) do update set platform = excluded.platform,
|
||||
user_id = excluded.user_id
|
||||
`,
|
||||
[token, 'android', userId]
|
||||
);
|
||||
return {success: true};
|
||||
[token, 'android', userId],
|
||||
)
|
||||
return {success: true}
|
||||
} catch (err) {
|
||||
console.error('Error saving subscription', err);
|
||||
console.error('Error saving subscription', err)
|
||||
throw new APIError(500, `Failed to save subscription`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
|
||||
export const saveSubscription: APIHandler<'save-subscription'> = async (body, auth) => {
|
||||
const {subscription} = body
|
||||
|
||||
@@ -13,29 +14,29 @@ export const saveSubscription: APIHandler<'save-subscription'> = async (body, au
|
||||
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]
|
||||
);
|
||||
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]
|
||||
);
|
||||
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]
|
||||
);
|
||||
[subscription.endpoint, subscription.keys, userId],
|
||||
)
|
||||
}
|
||||
|
||||
return {success: true};
|
||||
return {success: true}
|
||||
} catch (err) {
|
||||
console.error('Error saving subscription', err);
|
||||
console.error('Error saving subscription', err)
|
||||
throw new APIError(500, `Failed to save subscription`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {geodbFetch} from 'common/geodb'
|
||||
|
||||
import {APIHandler} from './helpers/endpoint'
|
||||
import {geodbFetch} from "common/geodb";
|
||||
|
||||
export const searchLocation: APIHandler<'search-location'> = async (body) => {
|
||||
const {term, limit} = body
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {geodbFetch} from 'common/geodb'
|
||||
|
||||
import {APIHandler} from './helpers/endpoint'
|
||||
import {geodbFetch} from "common/geodb";
|
||||
|
||||
const searchNearCityMain = async (cityId: string, radius: number) => {
|
||||
const endpoint = `/cities/${cityId}/nearbyCities?radius=${radius}&offset=0&sort=-population&limit=100`
|
||||
@@ -13,8 +14,6 @@ export const searchNearCity: APIHandler<'search-near-city'> = async (body) => {
|
||||
|
||||
export const getNearbyCities = async (cityId: string, radius: number) => {
|
||||
const result = await searchNearCityMain(cityId, radius)
|
||||
const cityIds = (result.data.data as any[]).map(
|
||||
(city) => city.id.toString() as string
|
||||
)
|
||||
const cityIds = (result.data.data as any[]).map((city) => city.id.toString() as string)
|
||||
return cityIds
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import {constructPrefixTsQuery} from 'shared/helpers/search'
|
||||
import {from, limit, orderBy, renderSql, select, where,} from 'shared/supabase/sql-builder'
|
||||
import {type APIHandler} from './helpers/endpoint'
|
||||
import {convertUser} from 'common/supabase/users'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {toUserAPIResponse} from 'common/api/user-types'
|
||||
import {convertUser} from 'common/supabase/users'
|
||||
import {uniqBy} from 'lodash'
|
||||
import {constructPrefixTsQuery} from 'shared/helpers/search'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {from, limit, orderBy, renderSql, select, where} from 'shared/supabase/sql-builder'
|
||||
|
||||
import {type APIHandler} from './helpers/endpoint'
|
||||
|
||||
export const searchUsers: APIHandler<'search-users'> = async (props, _auth) => {
|
||||
const {term, page, limit} = props
|
||||
@@ -45,19 +46,19 @@ function getSearchUserSQL(props: {
|
||||
[select('*'), from('users')],
|
||||
term
|
||||
? [
|
||||
where(
|
||||
`name_username_vector @@ websearch_to_tsquery('english', $1)
|
||||
where(
|
||||
`name_username_vector @@ websearch_to_tsquery('english', $1)
|
||||
or name_username_vector @@ to_tsquery('english', $2)`,
|
||||
[term, constructPrefixTsQuery(term)]
|
||||
),
|
||||
[term, constructPrefixTsQuery(term)],
|
||||
),
|
||||
|
||||
orderBy(
|
||||
`ts_rank(name_username_vector, websearch_to_tsquery($1)) desc,
|
||||
orderBy(
|
||||
`ts_rank(name_username_vector, websearch_to_tsquery($1)) desc,
|
||||
data->>'lastBetTime' desc nulls last`,
|
||||
[term]
|
||||
),
|
||||
]
|
||||
[term],
|
||||
),
|
||||
]
|
||||
: orderBy(`data->'creatorTraders'->'allTime' desc nulls last`),
|
||||
limit(props.limit, props.offset)
|
||||
limit(props.limit, props.offset),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import {createSupabaseDirectClient} from "shared/supabase/init";
|
||||
import {from, renderSql, select} from "shared/supabase/sql-builder";
|
||||
import {loadProfiles, profileQueryType} from "api/get-profiles";
|
||||
import {Row} from "common/supabase/utils";
|
||||
import {sendSearchAlertsEmail} from "email/functions/helpers";
|
||||
import {MatchesByUserType} from "common/profiles/bookmarked_searches";
|
||||
import {keyBy} from "lodash";
|
||||
import {loadProfiles, profileQueryType} from 'api/get-profiles'
|
||||
import {MatchesByUserType} from 'common/profiles/bookmarked_searches'
|
||||
import {Row} from 'common/supabase/utils'
|
||||
import {sendSearchAlertsEmail} from 'email/functions/helpers'
|
||||
import {keyBy} from 'lodash'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {from, renderSql, select} from 'shared/supabase/sql-builder'
|
||||
|
||||
export function convertSearchRow(row: any): any {
|
||||
return row
|
||||
}
|
||||
|
||||
|
||||
export const notifyBookmarkedSearch = async (matches: MatchesByUserType) => {
|
||||
for (const [_, value] of Object.entries(matches)) {
|
||||
await sendSearchAlertsEmail(value.user, value.privateUser, value.matches)
|
||||
@@ -20,43 +19,39 @@ export const notifyBookmarkedSearch = async (matches: MatchesByUserType) => {
|
||||
export const sendSearchNotifications = async () => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const search_query = renderSql(
|
||||
select('bookmarked_searches.*'),
|
||||
from('bookmarked_searches'),
|
||||
)
|
||||
const searches = await pg.map(search_query, [], convertSearchRow) as Row<'bookmarked_searches'>[]
|
||||
const search_query = renderSql(select('bookmarked_searches.*'), from('bookmarked_searches'))
|
||||
const searches = (await pg.map(
|
||||
search_query,
|
||||
[],
|
||||
convertSearchRow,
|
||||
)) as Row<'bookmarked_searches'>[]
|
||||
console.debug(`Running ${searches.length} bookmarked searches`)
|
||||
|
||||
const _users = await pg.map(
|
||||
renderSql(
|
||||
select('users.*'),
|
||||
from('users'),
|
||||
),
|
||||
const _users = (await pg.map(
|
||||
renderSql(select('users.*'), from('users')),
|
||||
[],
|
||||
convertSearchRow
|
||||
) as Row<'users'>[]
|
||||
convertSearchRow,
|
||||
)) as Row<'users'>[]
|
||||
const users = keyBy(_users, 'id')
|
||||
console.debug('users', users)
|
||||
|
||||
const _privateUsers = await pg.map(
|
||||
renderSql(
|
||||
select('private_users.*'),
|
||||
from('private_users'),
|
||||
),
|
||||
const _privateUsers = (await pg.map(
|
||||
renderSql(select('private_users.*'), from('private_users')),
|
||||
[],
|
||||
convertSearchRow
|
||||
) as Row<'private_users'>[]
|
||||
convertSearchRow,
|
||||
)) as Row<'private_users'>[]
|
||||
const privateUsers = keyBy(_privateUsers, 'id')
|
||||
console.debug('privateUsers', privateUsers)
|
||||
|
||||
const matches: MatchesByUserType = {}
|
||||
|
||||
for (const row of searches) {
|
||||
if (typeof row.search_filters !== 'object') continue;
|
||||
const { orderBy: _, ...filters } = (row.search_filters ?? {}) as Record<string, any>
|
||||
if (typeof row.search_filters !== 'object') continue
|
||||
const {orderBy: _, ...filters} = (row.search_filters ?? {}) as Record<string, any>
|
||||
const props = {
|
||||
...filters,
|
||||
skipId: row.creator_id,
|
||||
userId: row.creator_id,
|
||||
lastModificationWithin: '24 hours',
|
||||
shortBio: true,
|
||||
}
|
||||
@@ -84,4 +79,4 @@ export const sendSearchNotifications = async () => {
|
||||
await notifyBookmarkedSearch(matches)
|
||||
|
||||
return {status: 'success'}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import "tsconfig-paths/register";
|
||||
import * as admin from 'firebase-admin'
|
||||
import {initAdmin} from 'shared/init-admin'
|
||||
import 'tsconfig-paths/register'
|
||||
|
||||
import {IS_LOCAL} from 'common/hosting/constants'
|
||||
import {loadSecretsToEnv} from 'common/secrets'
|
||||
import {log} from 'shared/utils'
|
||||
import {IS_LOCAL} from "common/hosting/constants";
|
||||
import * as admin from 'firebase-admin'
|
||||
import {getServiceAccountCredentials} from 'shared/firebase-utils'
|
||||
import {initAdmin} from 'shared/init-admin'
|
||||
import {METRIC_WRITER} from 'shared/monitoring/metric-writer'
|
||||
import {log} from 'shared/utils'
|
||||
import {listen as webSocketListen} from 'shared/websockets/server'
|
||||
|
||||
import {app} from './app'
|
||||
|
||||
log('Api server starting up....')
|
||||
|
||||
if (IS_LOCAL) {
|
||||
@@ -21,13 +25,10 @@ if (IS_LOCAL) {
|
||||
|
||||
METRIC_WRITER.start()
|
||||
|
||||
import {app} from './app'
|
||||
import {getServiceAccountCredentials} from "shared/firebase-utils";
|
||||
|
||||
const credentials = IS_LOCAL
|
||||
? getServiceAccountCredentials()
|
||||
: // No explicit credentials needed for deployed service.
|
||||
undefined
|
||||
undefined
|
||||
|
||||
const startupProcess = async () => {
|
||||
await loadSecretsToEnv(credentials)
|
||||
@@ -40,4 +41,4 @@ const startupProcess = async () => {
|
||||
|
||||
webSocketListen(httpServer, '/ws')
|
||||
}
|
||||
startupProcess().then(_r => log('Server started successfully'))
|
||||
startupProcess().then((_r) => log('Server started successfully'))
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import {APIHandler} from './helpers/endpoint'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {Row} from 'common/supabase/utils'
|
||||
import {recomputeCompatibilityScoresForUser} from 'shared/compatibility/compute-scores'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
import {APIHandler} from './helpers/endpoint'
|
||||
|
||||
export const setCompatibilityAnswer: APIHandler<'set-compatibility-answer'> = async (
|
||||
{questionId, multipleChoice, prefChoices, importance, explanation},
|
||||
auth
|
||||
auth,
|
||||
) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
@@ -21,14 +22,7 @@ export const setCompatibilityAnswer: APIHandler<'set-compatibility-answer'> = as
|
||||
explanation = EXCLUDED.explanation
|
||||
RETURNING *
|
||||
`,
|
||||
values: [
|
||||
auth.uid,
|
||||
questionId,
|
||||
multipleChoice,
|
||||
prefChoices,
|
||||
importance,
|
||||
explanation ?? null,
|
||||
],
|
||||
values: [auth.uid, questionId, multipleChoice, prefChoices, importance, explanation ?? null],
|
||||
})
|
||||
|
||||
const continuation = async () => {
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import {APIHandler} from './helpers/endpoint'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
export const setLastOnlineTime: APIHandler<'set-last-online-time'> = async (
|
||||
_,
|
||||
auth
|
||||
) => {
|
||||
import {APIHandler} from './helpers/endpoint'
|
||||
|
||||
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(`
|
||||
await pg.none(
|
||||
`
|
||||
INSERT INTO user_activity (user_id, last_online_time)
|
||||
VALUES ($1, now())
|
||||
ON CONFLICT (user_id)
|
||||
@@ -21,6 +19,6 @@ export const setLastOnlineTimeUser = async (userId: string) => {
|
||||
SET last_online_time = EXCLUDED.last_online_time
|
||||
WHERE user_activity.last_online_time < now() - interval '1 minute';
|
||||
`,
|
||||
[userId]
|
||||
[userId],
|
||||
)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user