mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-04-04 06:51:45 -04:00
Compare commits
423 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d121c92708 | ||
|
|
2db74bf256 | ||
|
|
0d758eb5b1 | ||
|
|
223387129e | ||
|
|
01944f9a73 | ||
|
|
f9a1dce7b5 | ||
|
|
98271784b0 | ||
|
|
2311fcbf90 | ||
|
|
f6fef171fa | ||
|
|
7565d373e9 | ||
|
|
c4fb694c71 | ||
|
|
bdf8793d4a | ||
|
|
05bd25c9ab | ||
|
|
5dc98fcbcf | ||
|
|
91f11161a2 | ||
|
|
0c60e8a865 | ||
|
|
3c8566a9e8 | ||
|
|
15f0dd5aaf | ||
|
|
fc199a918a | ||
|
|
b936466a9d | ||
|
|
781b5ec674 | ||
|
|
a3dfcb4080 | ||
|
|
0fe1ffe78e | ||
|
|
e280b1f5e0 | ||
|
|
cc43a8f8af | ||
|
|
3caf56247f | ||
|
|
032c70e086 | ||
|
|
903679eaa7 | ||
|
|
2908a1f16d | ||
|
|
b5deefbec1 | ||
|
|
0cb6226643 | ||
|
|
b19f6b81de | ||
|
|
401fd816dd | ||
|
|
a31765b7ae | ||
|
|
a7ae62a14b | ||
|
|
007bc1f7b0 | ||
|
|
bded8cc1fe | ||
|
|
cfaac3e3fa | ||
|
|
9637c80dd7 | ||
|
|
6a9739ab31 | ||
|
|
a51bd344a2 | ||
|
|
50f9a00689 | ||
|
|
54fb8c4b61 | ||
|
|
809a996870 | ||
|
|
85c96ce430 | ||
|
|
b670de9c73 | ||
|
|
626b28f4eb | ||
|
|
9c3dd65fc9 | ||
|
|
1e5c7b07c2 | ||
|
|
686c5777fd | ||
|
|
611e07a02b | ||
|
|
3c514de79d | ||
|
|
8319d21dea | ||
|
|
e4a231b0c5 | ||
|
|
b1efd042cf | ||
|
|
8a7d2120c4 | ||
|
|
e0b3f2d81a | ||
|
|
9f1eaef30c | ||
|
|
0218f4e705 | ||
|
|
3171f32cec | ||
|
|
156d2870cb | ||
|
|
2229d01aa1 | ||
|
|
6e4c6f29b5 | ||
|
|
ad51aea069 | ||
|
|
b8963c99a7 | ||
|
|
b270ce706b | ||
|
|
2c04b2a6aa | ||
|
|
a00b966e8b | ||
|
|
8ac3259f41 | ||
|
|
56f0423d5a | ||
|
|
e83ec6506c | ||
|
|
be22658883 | ||
|
|
bdafa43472 | ||
|
|
891b91d0ba | ||
|
|
b85b9380c3 | ||
|
|
cfeab0278c | ||
|
|
c8735b8b01 | ||
|
|
5ecbd3ba91 | ||
|
|
f83dbf349e | ||
|
|
3d1e91d100 | ||
|
|
0353a530d4 | ||
|
|
0592c7e766 | ||
|
|
5a8c698ed5 | ||
|
|
7ff38bd693 | ||
|
|
6c84926033 | ||
|
|
f35af89f07 | ||
|
|
b1f01fd873 | ||
|
|
96e22136a4 | ||
|
|
f81932e14e | ||
|
|
6b1813e129 | ||
|
|
307076d88e | ||
|
|
a84ad62ea2 | ||
|
|
c3f21058e5 | ||
|
|
d3634d8b1c | ||
|
|
cdbba244d0 | ||
|
|
6daeea908e | ||
|
|
b835a5f137 | ||
|
|
25358a9463 | ||
|
|
11063611bf | ||
|
|
6a03c5cc83 | ||
|
|
37ddf5bab1 | ||
|
|
f741648522 | ||
|
|
57e6395641 | ||
|
|
bc31df7d0a | ||
|
|
1e4b836985 | ||
|
|
5c1b18b4d9 | ||
|
|
f90b2179b5 | ||
|
|
0da158ce54 | ||
|
|
a360b51e12 | ||
|
|
3de6adae2e | ||
|
|
01a6a6e298 | ||
|
|
54ce5891f6 | ||
|
|
8a2bcad190 | ||
|
|
a88ba2dd3c | ||
|
|
3503110c64 | ||
|
|
33436c84a4 | ||
|
|
52f0f04194 | ||
|
|
3d56bb4fe0 | ||
|
|
b0c84687d2 | ||
|
|
0ef5ecea30 | ||
|
|
670e863bae | ||
|
|
3309ed1988 | ||
|
|
3365445538 | ||
|
|
9808b4a2e7 | ||
|
|
f3bd28e29f | ||
|
|
da9e950e5f | ||
|
|
dbf12a2ab2 | ||
|
|
1a2aa16645 | ||
|
|
a1df61edaa | ||
|
|
9e58e12013 | ||
|
|
a71c0beb11 | ||
|
|
93e6b18b49 | ||
|
|
6aae66f0d2 | ||
|
|
46f751b712 | ||
|
|
ccce2cc8b0 | ||
|
|
c38d752dc8 | ||
|
|
6f45c03a29 | ||
|
|
5819f08aec | ||
|
|
a322ea77fc | ||
|
|
9a5f47f905 | ||
|
|
a02ba9767b | ||
|
|
57edf80bfd | ||
|
|
3a2db534ab | ||
|
|
8eac568446 | ||
|
|
5e5015018f | ||
|
|
1fce55aebc | ||
|
|
cae5b96b1e | ||
|
|
3c72bca496 | ||
|
|
ba7e158af8 | ||
|
|
34a13458db | ||
|
|
3200e3cf79 | ||
|
|
de9c28965f | ||
|
|
4e61669361 | ||
|
|
09607ba7c7 | ||
|
|
24d2fe9c32 | ||
|
|
94585b1f1d | ||
|
|
155406935d | ||
|
|
b445db6116 | ||
|
|
d4de56873f | ||
|
|
6c54a9adf0 | ||
|
|
74f948e6ca | ||
|
|
0ea9ee969e | ||
|
|
6ae1af3c1f | ||
|
|
4ac4ab0ba2 | ||
|
|
d29edae5fe | ||
|
|
066a620bd4 | ||
|
|
7ad464150b | ||
|
|
596e70e031 | ||
|
|
13f103a3ca | ||
|
|
a699447e9e | ||
|
|
159e634a1a | ||
|
|
0533fdd2ed | ||
|
|
5119c458d8 | ||
|
|
d8a39f7101 | ||
|
|
3a0712c193 | ||
|
|
cb9dd51afc | ||
|
|
89ce1a248e | ||
|
|
ffc717c86b | ||
|
|
30248fd0be | ||
|
|
c270e6c3d7 | ||
|
|
e8bc9cda1d | ||
|
|
0bc82a3bcf | ||
|
|
a5f7898c37 | ||
|
|
1165927337 | ||
|
|
2d5690cea2 | ||
|
|
ace1b2823a | ||
|
|
a50323cd94 | ||
|
|
008bc11ebf | ||
|
|
3ddf81d935 | ||
|
|
67e95be2d4 | ||
|
|
0bb0a394ae | ||
|
|
cc74945371 | ||
|
|
2ea34189a8 | ||
|
|
66800d949b | ||
|
|
2eb80b97d5 | ||
|
|
f4d8822dbe | ||
|
|
0655266366 | ||
|
|
4b58e72607 | ||
|
|
29445a8aa7 | ||
|
|
c4a498227f | ||
|
|
295fa1dee4 | ||
|
|
ca582f0134 | ||
|
|
43abe21e45 | ||
|
|
c1df4c1307 | ||
|
|
73802c9c1d | ||
|
|
2825ded7c0 | ||
|
|
69161612f6 | ||
|
|
2cc6af1f37 | ||
|
|
7a52f55b05 | ||
|
|
f854476614 | ||
|
|
7165553080 | ||
|
|
fbda1caaf7 | ||
|
|
1c3ed84791 | ||
|
|
6008a5d3a5 | ||
|
|
205354c6c4 | ||
|
|
cb8ef458c2 | ||
|
|
d54f0052df | ||
|
|
d979a81b95 | ||
|
|
bf8ce092af | ||
|
|
c53039d97a | ||
|
|
5f32e5d025 | ||
|
|
b3d203afa2 | ||
|
|
0379c95f9b | ||
|
|
512406837d | ||
|
|
32e8c8570b | ||
|
|
6c86de75ec | ||
|
|
4bc91a5311 | ||
|
|
822f9150b8 | ||
|
|
6117e59226 | ||
|
|
bf9d25731c | ||
|
|
8686ac4090 | ||
|
|
dcacf98ea3 | ||
|
|
cd9fcb8176 | ||
|
|
d158eadf0d | ||
|
|
e115df8e11 | ||
|
|
140ace55bf | ||
|
|
7a44f3d23c | ||
|
|
2f38d54ea5 | ||
|
|
01deda29e7 | ||
|
|
b59b0edd4a | ||
|
|
be358d8517 | ||
|
|
4e3f31dd1c | ||
|
|
ab439bd85d | ||
|
|
aa7e32cb77 | ||
|
|
863fd2c0ae | ||
|
|
a18d308248 | ||
|
|
bfed23769e | ||
|
|
54a8f0e59b | ||
|
|
90d25c7152 | ||
|
|
9ccdeb6997 | ||
|
|
ad1b3e813e | ||
|
|
50949199f4 | ||
|
|
2d477e498f | ||
|
|
f9f9da63a0 | ||
|
|
000daa3021 | ||
|
|
40c30ede11 | ||
|
|
54fdf67bcf | ||
|
|
1bf9b83693 | ||
|
|
32e97f9da5 | ||
|
|
677f8bf207 | ||
|
|
ab92cf2aa9 | ||
|
|
0dff23991a | ||
|
|
04af8966b5 | ||
|
|
dd239f7b30 | ||
|
|
d2195d7c16 | ||
|
|
60269b66a7 | ||
|
|
2cad2fca17 | ||
|
|
a10ae2d253 | ||
|
|
4a4bee658d | ||
|
|
75a689707d | ||
|
|
6f638a22a3 | ||
|
|
18b63f1eb3 | ||
|
|
39689b1bfa | ||
|
|
44bc25f061 | ||
|
|
e2d9c06362 | ||
|
|
165a7e5663 | ||
|
|
8f83011011 | ||
|
|
9924c3debf | ||
|
|
836f8f1bfb | ||
|
|
fae76195ec | ||
|
|
0d8d81e09c | ||
|
|
699890a0be | ||
|
|
8c68312597 | ||
|
|
55bb9919f7 | ||
|
|
f8ca4bcbfc | ||
|
|
7037362b40 | ||
|
|
e29bc0ab82 | ||
|
|
b3cf542fd5 | ||
|
|
59ddb4360e | ||
|
|
4411ef25b0 | ||
|
|
0d57760d25 | ||
|
|
77f3b550d0 | ||
|
|
79e0421281 | ||
|
|
f54e18feb1 | ||
|
|
18d2c59479 | ||
|
|
33d7308cfa | ||
|
|
579ed6de7c | ||
|
|
8a1ee5cdca | ||
|
|
edaf119d9e | ||
|
|
1aad769d93 | ||
|
|
b5b2bafc78 | ||
|
|
8ba8604d83 | ||
|
|
9fdd21e03a | ||
|
|
418b2c7e52 | ||
|
|
49237bbe18 | ||
|
|
049fffe27f | ||
|
|
8d80245adf | ||
|
|
8d235e89f0 | ||
|
|
b030dd1a52 | ||
|
|
17faf2fe26 | ||
|
|
b18a6d7ff3 | ||
|
|
c69a438d08 | ||
|
|
309cbe7f2b | ||
|
|
c0df0028d3 | ||
|
|
4722088fd0 | ||
|
|
27c03330c8 | ||
|
|
740a7cc6f9 | ||
|
|
53ae605e9d | ||
|
|
84da8b7ad3 | ||
|
|
8b283cc5ce | ||
|
|
8548b85d03 | ||
|
|
fbb10344e1 | ||
|
|
615033547c | ||
|
|
8f854995c5 | ||
|
|
f8bb15e376 | ||
|
|
f6a65e875b | ||
|
|
74fc6a744e | ||
|
|
6920b8293d | ||
|
|
6c71022ed6 | ||
|
|
d0176c2b65 | ||
|
|
5ce38fea65 | ||
|
|
19ee048536 | ||
|
|
2531ee6fe4 | ||
|
|
1722cb531f | ||
|
|
f59325cbed | ||
|
|
1c595d3e33 | ||
|
|
4f2df43232 | ||
|
|
b7fe357fb2 | ||
|
|
59d52d4c11 | ||
|
|
8c1a75e26b | ||
|
|
ce8e7d141a | ||
|
|
0a2e4a7df1 | ||
|
|
26bc68e4db | ||
|
|
945f4a0d82 | ||
|
|
41da848714 | ||
|
|
5a92c47c99 | ||
|
|
69f181e8ee | ||
|
|
f374fef4f9 | ||
|
|
263e38f23e | ||
|
|
ddd5cd6823 | ||
|
|
7f8f394d58 | ||
|
|
57d9d2df38 | ||
|
|
b7500ba634 | ||
|
|
615d56131f | ||
|
|
c6f4b05e2a | ||
|
|
366581bcb1 | ||
|
|
35d96fff5d | ||
|
|
24e088b599 | ||
|
|
432d2df449 | ||
|
|
68a79c4b90 | ||
|
|
fa922bdcbe | ||
|
|
1086f6b4e2 | ||
|
|
44d3e7577b | ||
|
|
4015db7fda | ||
|
|
04f41c42c4 | ||
|
|
67fb98c672 | ||
|
|
8c21d2990f | ||
|
|
32201b6dfa | ||
|
|
4326c870a8 | ||
|
|
e03c714555 | ||
|
|
59cb649540 | ||
|
|
77e40c088c | ||
|
|
e5aeda92c8 | ||
|
|
0e99f75b73 | ||
|
|
9ec5fe549b | ||
|
|
47cf7bd3b2 | ||
|
|
c848007874 | ||
|
|
abba1260be | ||
|
|
cfc6b45a5b | ||
|
|
5e8f8167d1 | ||
|
|
dce0821b1a | ||
|
|
129dde8713 | ||
|
|
5d368a61eb | ||
|
|
2d0a869b00 | ||
|
|
88efbe4666 | ||
|
|
46aba5dc8d | ||
|
|
5321dd5690 | ||
|
|
2b0cd7ad3a | ||
|
|
3c08ba3cae | ||
|
|
f850b4ada5 | ||
|
|
1dbe4ecdef | ||
|
|
2b31ed3164 | ||
|
|
df2473929a | ||
|
|
80a877301a | ||
|
|
1aae688f3f | ||
|
|
337ce4523f | ||
|
|
e0b26af2bc | ||
|
|
1e2c2bbb8f | ||
|
|
ab0fd0aea4 | ||
|
|
1310c423bd | ||
|
|
32fadcc194 | ||
|
|
a2959a773e | ||
|
|
cadb4a4fd5 | ||
|
|
8decdab0c3 | ||
|
|
b710fa9f60 | ||
|
|
3cb5d08801 | ||
|
|
aa785c1539 | ||
|
|
f0c645b16d | ||
|
|
9870ac5029 | ||
|
|
cd067cd1a9 | ||
|
|
e1805d9d9e | ||
|
|
23a8aa6712 | ||
|
|
f70a74d20e | ||
|
|
02ea9131e4 | ||
|
|
cd8096f524 | ||
|
|
52819f3259 | ||
|
|
55c1b3983d | ||
|
|
27ac1539cb | ||
|
|
119bd9699d | ||
|
|
607285f25d | ||
|
|
192a944f4b | ||
|
|
c085e8f6dd | ||
|
|
79f855d39a |
File diff suppressed because it is too large
Load Diff
33
.github/actions/setup/action.yml
vendored
Normal file
33
.github/actions/setup/action.yml
vendored
Normal file
@@ -0,0 +1,33 @@
|
||||
name: Setup
|
||||
description: Checkout, cache and install dependencies
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Cache node_modules
|
||||
id: cache-node-modules
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
node_modules
|
||||
web/node_modules
|
||||
backend/api/node_modules
|
||||
backend/shared/node_modules
|
||||
backend/email/node_modules
|
||||
common/node_modules
|
||||
key: node-modules-${{ hashFiles('**/yarn.lock') }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||
run: yarn install --frozen-lockfile
|
||||
shell: bash
|
||||
|
||||
- name: Post-install
|
||||
if: steps.cache-node-modules.outputs.cache-hit == 'true'
|
||||
run: yarn postinstall
|
||||
shell: bash
|
||||
1365
.github/copilot-instructions.md
vendored
1365
.github/copilot-instructions.md
vendored
File diff suppressed because it is too large
Load Diff
33
.github/workflows/cd-android-live-update.yml
vendored
33
.github/workflows/cd-android-live-update.yml
vendored
@@ -7,18 +7,17 @@ on:
|
||||
- '.github/workflows/cd-android-live-update.yml'
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy
|
||||
check-version:
|
||||
name: Check Version
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
changed: ${{ steps.check.outputs.changed }}
|
||||
|
||||
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
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Read current version
|
||||
id: current
|
||||
@@ -29,7 +28,6 @@ jobs:
|
||||
- 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
|
||||
@@ -37,32 +35,31 @@ jobs:
|
||||
fi
|
||||
echo "version=$previous" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check version change
|
||||
- name: Compare versions
|
||||
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 "Version unchanged. Skipping deploy."
|
||||
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'
|
||||
deploy:
|
||||
name: Deploy
|
||||
runs-on: ubuntu-latest
|
||||
needs: check-version
|
||||
if: needs.check-version.outputs.changed == 'true'
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.check.outputs.changed == 'true'
|
||||
run: yarn install
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup
|
||||
|
||||
- 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 }}
|
||||
|
||||
88
.github/workflows/cd-android.yml
vendored
Normal file
88
.github/workflows/cd-android.yml
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
name: Android Release
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'android/app/build.gradle'
|
||||
- '.github/workflows/cd-android.yml'
|
||||
|
||||
jobs:
|
||||
check-version:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
should_build: ${{ steps.version_check.outputs.should_build }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Get previous versionCode
|
||||
id: prev_version
|
||||
run: |
|
||||
git checkout HEAD^
|
||||
PREV=$(grep versionCode android/app/build.gradle | awk '{print $2}')
|
||||
echo "prev_version=$PREV" >> $GITHUB_OUTPUT
|
||||
git checkout -
|
||||
|
||||
- name: Get current versionCode
|
||||
id: curr_version
|
||||
run: |
|
||||
CURR=$(grep versionCode android/app/build.gradle | awk '{print $2}')
|
||||
echo "curr_version=$CURR" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Compare versionCodes
|
||||
id: version_check
|
||||
run: |
|
||||
if [ "${{ steps.curr_version.outputs.curr_version }}" -gt "${{ steps.prev_version.outputs.prev_version }}" ]; then
|
||||
echo "should_build=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "versionCode not increased. Skipping build."
|
||||
echo "should_build=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
build-and-release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: check-version
|
||||
if: needs.check-version.outputs.should_build == 'true'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup
|
||||
|
||||
- name: Set up JDK
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: 21
|
||||
cache: gradle
|
||||
|
||||
- name: Compile Web App into Android assets
|
||||
env:
|
||||
NEXT_PUBLIC_FIREBASE_API_KEY: ${{ secrets.NEXT_PUBLIC_FIREBASE_API_KEY }}
|
||||
NEXT_PUBLIC_SUPABASE_KEY: ${{ secrets.NEXT_PUBLIC_SUPABASE_KEY }}
|
||||
run: yarn build-sync-android
|
||||
|
||||
- name: Build AAB
|
||||
run: |
|
||||
cd android
|
||||
echo "${{ secrets.ANDROID_GOOGLE_SERVICES_JSON }}" | base64 -d > app/google-services.json
|
||||
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > release.keystore
|
||||
cp release.keystore app/release.keystore
|
||||
chmod +x gradlew
|
||||
./gradlew bundleRelease \
|
||||
-Pandroid.injected.signing.store.file=release.keystore \
|
||||
-Pandroid.injected.signing.store.password=${{ secrets.ANDROID_KEYSTORE_PASSWORD }} \
|
||||
-Pandroid.injected.signing.key.alias=compass \
|
||||
-Pandroid.injected.signing.key.password=${{ secrets.ANDROID_KEY_PASSWORD }}
|
||||
|
||||
- name: Upload to Play Store (Internal Track)
|
||||
uses: r0adkll/upload-google-play@v1
|
||||
with:
|
||||
serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }}
|
||||
packageName: com.compassconnections.app
|
||||
releaseFiles: android/app/build/outputs/bundle/release/app-release.aab
|
||||
track: internal
|
||||
45
.github/workflows/cd-api.yml
vendored
45
.github/workflows/cd-api.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: CD API
|
||||
name: API Release
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
@@ -7,18 +7,17 @@ on:
|
||||
- '.github/workflows/cd-api.yml'
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Deploy
|
||||
check-version:
|
||||
name: Check Version
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
changed: ${{ steps.check.outputs.changed }}
|
||||
|
||||
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
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Read current version
|
||||
id: current
|
||||
@@ -29,7 +28,6 @@ jobs:
|
||||
- name: Read previous version
|
||||
id: previous
|
||||
run: |
|
||||
# Get previous commit’s package.json (if it existed)
|
||||
if git show HEAD^:backend/api/package.json >/dev/null 2>&1; then
|
||||
previous=$(git show HEAD^:backend/api/package.json | jq -r '.version')
|
||||
else
|
||||
@@ -37,60 +35,53 @@ jobs:
|
||||
fi
|
||||
echo "version=$previous" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Check version change
|
||||
- name: Compare versions
|
||||
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 "Version unchanged. Skipping deploy."
|
||||
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'
|
||||
deploy:
|
||||
name: Deploy
|
||||
runs-on: ubuntu-latest
|
||||
needs: check-version
|
||||
if: needs.check-version.outputs.changed == 'true'
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.check.outputs.changed == 'true'
|
||||
run: yarn install
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup
|
||||
|
||||
- name: Authenticate to GCP
|
||||
if: steps.check.outputs.changed == 'true'
|
||||
uses: google-github-actions/auth@v2
|
||||
with:
|
||||
credentials_json: ${{ secrets.GCP_SA_KEY }}
|
||||
|
||||
- name: Install gcloud CLI
|
||||
if: steps.check.outputs.changed == 'true'
|
||||
uses: google-github-actions/setup-gcloud@v2
|
||||
with:
|
||||
project_id: compass-130ba
|
||||
|
||||
- name: Configure Docker for Artifact Registry
|
||||
if: steps.check.outputs.changed == 'true'
|
||||
run: |
|
||||
gcloud auth configure-docker us-west1-docker.pkg.dev --quiet
|
||||
run: gcloud auth configure-docker us-west1-docker.pkg.dev --quiet
|
||||
|
||||
- name: Install Tofu (Terraform)
|
||||
if: steps.check.outputs.changed == 'true'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y wget unzip
|
||||
LATEST=https://github.com/opentofu/opentofu/releases/download/v1.10.5/tofu_1.10.5_linux_amd64.zip
|
||||
curl -LO "$LATEST"
|
||||
unzip -o tofu_*_linux_amd64.zip
|
||||
sudo mv tofu /usr/local/bin/
|
||||
rm tofu_*_linux_amd64.zip
|
||||
echo "OpenTofu version: $(tofu version)"
|
||||
cd backend/api || exit 1
|
||||
cd backend/api
|
||||
tofu init
|
||||
|
||||
- name: Run deploy script
|
||||
if: steps.check.outputs.changed == 'true'
|
||||
run: |
|
||||
chmod +x backend/api/deploy-api.sh
|
||||
backend/api/deploy-api.sh
|
||||
|
||||
2
.github/workflows/cd.yml
vendored
2
.github/workflows/cd.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: CD
|
||||
name: GitHub Release
|
||||
|
||||
# Must select "Read and write permissions" in GitHub → Repo → Settings → Actions → General → Workflow permissions
|
||||
|
||||
|
||||
34
.github/workflows/ci-e2e.yml
vendored
34
.github/workflows/ci-e2e.yml
vendored
@@ -13,14 +13,21 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
- name: Cache Firebase emulators
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'yarn'
|
||||
path: ~/.cache/firebase/emulators
|
||||
key: firebase-emulators-${{ hashFiles('firebase.json') }}
|
||||
restore-keys: firebase-emulators-
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ hashFiles('package.json') }}
|
||||
|
||||
- name: Install Java (for Firebase emulators)
|
||||
uses: actions/setup-java@v4
|
||||
@@ -28,17 +35,13 @@ jobs:
|
||||
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
|
||||
|
||||
# Docker load from cache is actually slower than pulling the images every time with supabase start
|
||||
- name: Start Supabase
|
||||
run: ./scripts/supabase_start.sh
|
||||
|
||||
- name: Run E2E tests
|
||||
env:
|
||||
SKIP_DB_CLEANUP: true # Don't try to stop Docker in CI
|
||||
@@ -52,7 +55,7 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
path: tests/reports/playwright-report/
|
||||
retention-days: 7
|
||||
|
||||
- name: Upload test results
|
||||
@@ -62,3 +65,4 @@ jobs:
|
||||
name: test-results
|
||||
path: test-results/
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
44
.github/workflows/ci.yml
vendored
44
.github/workflows/ci.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Jest Tests
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -7,47 +7,39 @@ on:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
name: Jest Tests
|
||||
lint:
|
||||
name: Lint & Typecheck
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
timeout-minutes: 5
|
||||
|
||||
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 dependencies
|
||||
run: yarn install --frozen-lockfile
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup
|
||||
|
||||
- name: Lint
|
||||
run: yarn lint
|
||||
|
||||
- name: Type check
|
||||
- name: Typecheck
|
||||
run: yarn typecheck
|
||||
|
||||
test:
|
||||
name: Jest Tests
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: ./.github/actions/setup
|
||||
|
||||
- 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
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
if: success()
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
files: |
|
||||
@@ -59,5 +51,3 @@ jobs:
|
||||
flags: unit
|
||||
fail_ci_if_error: true
|
||||
slug: CompassConnections/Compass
|
||||
env:
|
||||
CI: true
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -100,3 +100,5 @@ test-results
|
||||
/.nyc_output/
|
||||
|
||||
**/coverage
|
||||
|
||||
*my-release-key.keystore
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
npx lint-staged
|
||||
yarn --cwd=web lint-fix
|
||||
yarn --cwd=web lint
|
||||
npx lint-staged
|
||||
@@ -514,7 +514,7 @@ What testing is not
|
||||
|
||||
How we apply it here
|
||||
|
||||
- Unit and integration tests live in each package and run with Jest (see `jest.config.js`).
|
||||
- Unit and integration tests live in each package and run with Jest (see `jest.config.ts`).
|
||||
- Critical user journeys are covered by Playwright E2E tests under `tests/e2e` (see `playwright.config.ts`).
|
||||
|
||||
### Test types at a glance
|
||||
@@ -525,7 +525,7 @@ This project uses three complementary test types. Use the right level for the jo
|
||||
- Purpose: Verify a single function/module in isolation; fast, deterministic.
|
||||
- Where: Each package under `tests/unit` (e.g., `backend/api/tests/unit`, `web/tests/unit`, `common/tests/unit`,
|
||||
etc.).
|
||||
- Runner: Jest (configured via root `jest.config.js`).
|
||||
- Runner: Jest (configured via root `jest.config.ts`).
|
||||
- Naming: `*.unit.test.ts` (or `.tsx` for React in `web`).
|
||||
- When to use: Pure logic, utilities, hooks, reducers, small components with mocked dependencies.
|
||||
|
||||
@@ -533,7 +533,7 @@ This project uses three complementary test types. Use the right level for the jo
|
||||
- Purpose: Verify multiple units working together (e.g., function + DB/client, component + context/provider) without
|
||||
spinning up the full app.
|
||||
- Where: Each package under `tests/integration` (e.g., `backend/shared/tests/integration`, `web/tests/integration`).
|
||||
- Runner: Jest (configured via root `jest.config.js`).
|
||||
- Runner: Jest (configured via root `jest.config.ts`).
|
||||
- Naming: `*.integration.test.ts` (or `.tsx` for React in `web`).
|
||||
- When to use: Boundaries between modules, real serialization/parsing, API handlers with mocked network/DB,
|
||||
component trees with providers.
|
||||
@@ -559,7 +559,7 @@ yarn test:e2e
|
||||
|
||||
```filetree
|
||||
# Config
|
||||
jest.config.js (for unit and integration tests)
|
||||
jest.config.ts (for unit and integration tests)
|
||||
playwright.config.ts (for e2e tests)
|
||||
|
||||
# Top-level End-to-End (Playwright)
|
||||
@@ -611,7 +611,7 @@ web/
|
||||
- End-to-End tests live under `tests/e2e` and are executed by Playwright. The root `playwright.config.ts` sets `testDir`
|
||||
to `./tests/e2e`.
|
||||
- Unit and integration tests live in each package’s `tests` folder and are executed by Jest via the root
|
||||
`jest.config.js` projects array.
|
||||
`jest.config.ts` projects array.
|
||||
- Naming:
|
||||
- Unit: `*.unit.test.ts` (or `.tsx` for React in `web`)
|
||||
- Integration: `*.integration.test.ts`
|
||||
|
||||
28
.lintstagedrc.mjs
Normal file
28
.lintstagedrc.mjs
Normal file
@@ -0,0 +1,28 @@
|
||||
export default {
|
||||
'web/**/*.{ts,tsx,js,jsx}': (files) => [
|
||||
`prettier --write ${files.join(' ')}`,
|
||||
`eslint --config web/eslint.config.mjs --fix ${files.join(' ')}`,
|
||||
`eslint --config web/eslint.config.mjs --max-warnings 0 ${files.join(' ')}`,
|
||||
],
|
||||
'common/**/*.{ts,tsx,js,jsx}': (files) => [
|
||||
`prettier --write ${files.join(' ')}`,
|
||||
`eslint --config common/eslint.config.mjs --fix ${files.join(' ')}`,
|
||||
`eslint --config common/eslint.config.mjs --max-warnings 0 ${files.join(' ')}`,
|
||||
],
|
||||
'backend/api/**/*.{ts,tsx,js,jsx}': (files) => [
|
||||
`prettier --write ${files.join(' ')}`,
|
||||
`eslint --config backend/api/eslint.config.mjs --fix ${files.join(' ')}`,
|
||||
`eslint --config backend/api/eslint.config.mjs --max-warnings 0 ${files.join(' ')}`,
|
||||
],
|
||||
'backend/shared/**/*.{ts,tsx,js,jsx}': (files) => [
|
||||
`prettier --write ${files.join(' ')}`,
|
||||
`eslint --config backend/shared/eslint.config.mjs --fix ${files.join(' ')}`,
|
||||
`eslint --config backend/shared/eslint.config.mjs --max-warnings 0 ${files.join(' ')}`,
|
||||
],
|
||||
'backend/email/**/*.{ts,tsx,js,jsx}': (files) => [
|
||||
`prettier --write ${files.join(' ')}`,
|
||||
`eslint --config backend/email/eslint.config.mjs --fix ${files.join(' ')}`,
|
||||
`eslint --config backend/email/eslint.config.mjs --max-warnings 0 ${files.join(' ')}`,
|
||||
],
|
||||
'**/*.{json,css,scss,md}': (files) => [`prettier --write ${files.join(' ')}`],
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
"bracketSpacing": false,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "all",
|
||||
"plugins": ["prettier-plugin-sql"],
|
||||
"plugins": ["prettier-plugin-sql", "prettier-plugin-packagejson"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.sql",
|
||||
|
||||
@@ -6,399 +6,290 @@ globs:
|
||||
|
||||
## Project Structure
|
||||
|
||||
- next.js react tailwind frontend `/web`
|
||||
- broken down into pages, components, hooks, lib
|
||||
- express node api server `/backend/api`
|
||||
- one off scripts, like migrations `/backend/scripts`
|
||||
- supabase postgres. schema in `/backend/supabase`
|
||||
- supabase-generated types in `/backend/supabase/schema.ts`
|
||||
- files shared between backend directories `/backend/shared`
|
||||
- anything in `/backend` can import from `shared`, but not vice versa
|
||||
- files shared between the frontend and backend in `/common`
|
||||
- `/common` has lots of type definitions for our data structures, like User. It also contains many useful utility functions. We try not to add package dependencies to common. `/web` and `/backend` are allowed to import from `/common`, but not vice versa.
|
||||
Compass (compassmeet.com) is a transparent dating platform for forming deep, authentic 1-on-1 connections.
|
||||
|
||||
- **Next.js React frontend** `/web`
|
||||
- Pages, components, hooks, lib
|
||||
- **Express Node API server** `/backend/api`
|
||||
- **Shared backend utilities** `/backend/shared`
|
||||
- **Email functions** `/backend/email`
|
||||
- **Database schema** `/backend/supabase`
|
||||
- Supabase-generated types in `/backend/supabase/schema.ts`
|
||||
- **Files shared between frontend and backend** `/common`
|
||||
- Types (User, Profile, etc.) and utilities
|
||||
- Try not to add package dependencies to common
|
||||
- **Android app** `/android`
|
||||
|
||||
## Deployment
|
||||
|
||||
- The project has both dev and prod environments.
|
||||
- Backend is on GCP (Google Cloud Platform). Deployment handled by terraform.
|
||||
- Project ID is `compass-130ba`.
|
||||
- Both dev and prod environments
|
||||
- Backend on GCP (Google Cloud Platform)
|
||||
- Frontend on Vercel
|
||||
- Database on Supabase (PostgreSQL)
|
||||
- Firebase for authentication and storage
|
||||
|
||||
## Code Guidelines
|
||||
|
||||
---
|
||||
|
||||
Here's an example component from web in our style:
|
||||
### Component Example
|
||||
|
||||
```tsx
|
||||
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 {User} from 'common/user'
|
||||
import {ProfileRow} from 'common/profiles/profile'
|
||||
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 {useT} from 'web/lib/locale'
|
||||
|
||||
export function HeadlineTabs(props: {
|
||||
headlines: Headline[]
|
||||
currentSlug: string
|
||||
endpoint: DashboardEndpoints
|
||||
hideEmoji?: boolean
|
||||
notSticky?: boolean
|
||||
className?: string
|
||||
}) {
|
||||
const {headlines, endpoint, currentSlug, hideEmoji, notSticky, className} = props
|
||||
const user = useUser()
|
||||
interface ProfileCardProps {
|
||||
user: User
|
||||
profile: ProfileRow
|
||||
}
|
||||
|
||||
export function ProfileCard({user, profile}: ProfileCardProps) {
|
||||
const t = useT()
|
||||
|
||||
return (
|
||||
<div className={clsx(className, 'bg-canvas-50 w-full', !notSticky && 'sticky top-0 z-50')}>
|
||||
<Carousel labelsParentClassName="gap-px">
|
||||
{headlines.map(({id, slug, title}) => (
|
||||
<Tab
|
||||
key={id}
|
||||
label={hideEmoji ? removeEmojis(title) : title}
|
||||
href={`/${endpoint}/${slug}`}
|
||||
active={slug === currentSlug}
|
||||
/>
|
||||
))}
|
||||
{user && <Tab label="More" href="/dashboard" />}
|
||||
{user && (isAdminId(user.id) || isModId(user.id)) && (
|
||||
<EditNewsButton endpoint={endpoint} defaultDashboards={headlines} />
|
||||
)}
|
||||
</Carousel>
|
||||
<div className={clsx('bg-canvas-50 rounded-lg p-4')}>
|
||||
<img src={user.avatarUrl} alt={user.name} />
|
||||
<h3>{user.name}</h3>
|
||||
<p>{profile.bio}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
We prefer many smaller components that each represent one logical unit, rather than one large component.
|
||||
|
||||
We prefer to have many smaller components that each represent one logical unit, rather than one very large component that does everything. Then we compose and reuse the components.
|
||||
Export the main component at the top of the file. Name the component the same as the file (e.g., `profile-card.tsx` →
|
||||
`ProfileCard`).
|
||||
|
||||
It's best to export the main component at the top of the file. We also try to name the component the same as the file name (headline-tabs.tsx) so that it's easy to find.
|
||||
### API Calls
|
||||
|
||||
Here's another example in `home.tsx` that calls our api. We have an endpoint called 'headlines', which is being cached by NextJS:
|
||||
**Server-side (getStaticProps):**
|
||||
|
||||
```ts
|
||||
import { api } from 'web/lib/api/api'
|
||||
// More imports...
|
||||
```typescript
|
||||
import {api} from 'web/lib/api'
|
||||
|
||||
export async function getStaticProps() {
|
||||
try {
|
||||
const headlines = await api('headlines', {})
|
||||
return {
|
||||
props: {
|
||||
headlines,
|
||||
revalidate: 30 * 60, // 30 minutes
|
||||
},
|
||||
}
|
||||
} catch (err) {
|
||||
return { props: { headlines: [] }, revalidate: 60 }
|
||||
const profiles = await api('get-profiles', {})
|
||||
return {
|
||||
props: {profiles},
|
||||
revalidate: 30 * 60, // 30 minutes
|
||||
}
|
||||
}
|
||||
|
||||
export default function Home(props: { headlines: Headline[] }) { ... }
|
||||
```
|
||||
|
||||
---
|
||||
**Client-side - use hooks:**
|
||||
|
||||
If we are calling the API on the client, prefer using the `useAPIGetter` hook:
|
||||
```typescript
|
||||
import {useAPIGetter} from 'web/hooks/use-api-getter'
|
||||
|
||||
```ts
|
||||
export const YourTopicsSection = (props: {
|
||||
user: User
|
||||
className?: string
|
||||
}) => {
|
||||
const { user, className } = props
|
||||
const { data, refresh } = useAPIGetter('get-followed-groups', {
|
||||
userId: user.id,
|
||||
})
|
||||
const followedGroups = data?.groups ?? []
|
||||
...
|
||||
```
|
||||
function ProfileList() {
|
||||
const {data, refresh} = useAPIGetter('get-profiles', {})
|
||||
|
||||
This stores the result in memory, and allows you to call refresh() to get an updated version.
|
||||
if (!data) return <Loading / >
|
||||
|
||||
---
|
||||
|
||||
We frequently use `usePersistentInMemoryState` or `usePersistentLocalState` as an alternative to `useState`. These cache data. Most of the time you want in-memory caching so that navigating back to a page will preserve the same state and appear to load instantly.
|
||||
|
||||
Here's the definition of usePersistentInMemoryState:
|
||||
|
||||
```ts
|
||||
export const usePersistentInMemoryState = <T>(initialValue: T, key: string) => {
|
||||
const [state, setState] = useStateCheckEquality<T>(safeJsonParse(store[key]) ?? initialValue)
|
||||
|
||||
useEffect(() => {
|
||||
const storedValue = safeJsonParse(store[key]) ?? initialValue
|
||||
setState(storedValue as T)
|
||||
}, [key])
|
||||
|
||||
const saveState = useEvent((newState: T | ((prevState: T) => T)) => {
|
||||
setState((prevState) => {
|
||||
const updatedState = isFunction(newState) ? newState(prevState) : newState
|
||||
store[key] = JSON.stringify(updatedState)
|
||||
return updatedState
|
||||
})
|
||||
})
|
||||
|
||||
return [state, saveState] as const
|
||||
return (
|
||||
<div>
|
||||
{
|
||||
data.profiles.map((profile) => (
|
||||
<ProfileCard key = {profile.id} user = {profile.user} profile = {profile}
|
||||
/>
|
||||
))
|
||||
}
|
||||
<button onClick = {refresh} > Refresh < /button>
|
||||
< /div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
### Database Access
|
||||
|
||||
For live updates, we use websockets. In `use-api-subscription.ts`, we have this hook:
|
||||
**Backend (pg-promise):**
|
||||
|
||||
```ts
|
||||
export function useApiSubscription(opts: SubscriptionOptions) {
|
||||
useEffect(() => {
|
||||
const ws = client
|
||||
if (ws != null) {
|
||||
if (opts.enabled ?? true) {
|
||||
ws.subscribe(opts.topics, opts.onBroadcast).catch(opts.onError)
|
||||
return () => {
|
||||
ws.unsubscribe(opts.topics, opts.onBroadcast).catch(opts.onError)
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [opts.enabled, JSON.stringify(opts.topics)])
|
||||
}
|
||||
```
|
||||
|
||||
In `use-bets`, we have this hook to get live updates with useApiSubscription:
|
||||
|
||||
```ts
|
||||
export const useContractBets = (
|
||||
contractId: string,
|
||||
opts?: APIParams<'bets'> & {enabled?: boolean},
|
||||
) => {
|
||||
const {enabled = true, ...apiOptions} = {
|
||||
contractId,
|
||||
...opts,
|
||||
}
|
||||
const optionsKey = JSON.stringify(apiOptions)
|
||||
|
||||
const [newBets, setNewBets] = usePersistentInMemoryState<Bet[]>([], `${optionsKey}-bets`)
|
||||
|
||||
const addBets = (bets: Bet[]) => {
|
||||
setNewBets((currentBets) => {
|
||||
const uniqueBets = sortBy(uniqBy([...currentBets, ...bets], 'id'), 'createdTime')
|
||||
return uniqueBets.filter((b) => !betShouldBeFiltered(b, apiOptions))
|
||||
})
|
||||
}
|
||||
|
||||
const isPageVisible = useIsPageVisible()
|
||||
|
||||
useEffect(() => {
|
||||
if (isPageVisible && enabled) {
|
||||
api('bets', apiOptions).then(addBets)
|
||||
}
|
||||
}, [optionsKey, enabled, isPageVisible])
|
||||
|
||||
useApiSubscription({
|
||||
topics: [`contract/${contractId}/new-bet`],
|
||||
onBroadcast: (msg) => {
|
||||
addBets(msg.data.bets as Bet[])
|
||||
},
|
||||
enabled,
|
||||
})
|
||||
|
||||
return newBets
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Here are all the topics we broadcast, from `backend/shared/src/websockets/helpers.ts`
|
||||
|
||||
```ts
|
||||
export function broadcastUpdatedPrivateUser(userId: string) {
|
||||
// don't send private user info because it's private and anyone can listen
|
||||
broadcast(`private-user/${userId}`, {})
|
||||
}
|
||||
|
||||
export function broadcastUpdatedUser(user: Partial<User> & {id: string}) {
|
||||
broadcast(`user/${user.id}`, {user})
|
||||
}
|
||||
|
||||
export function broadcastUpdatedComment(comment: Comment) {
|
||||
broadcast(`user/${comment.onUserId}/comment`, {comment})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
We have our scripts in the directory `/backend/scripts`.
|
||||
|
||||
To write a script, run it inside the helper function called `runScript` that automatically fetches any secret keys and loads them into process.env.
|
||||
|
||||
Example from `/backend/scripts/manicode.ts`
|
||||
|
||||
```ts
|
||||
import { runScript } from 'run-script'
|
||||
|
||||
runScript(async ({ pg }) => {
|
||||
const userPrompt = process.argv[2]
|
||||
await pg.none(...)
|
||||
})
|
||||
```
|
||||
|
||||
Generally scripts should be run by me, especially if they modify backend state or schema.
|
||||
But if you need to run a script, you can use `bun`. For example:
|
||||
|
||||
```sh
|
||||
bun run manicode.ts "Generate a page called cowp, which has cows that make noises!"
|
||||
```
|
||||
|
||||
if that doesn't work, try
|
||||
|
||||
```sh
|
||||
bun x ts-node manicode.ts "Generate a page called cowp, which has cows that make noises!"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Our backend is mostly a set of endpoints. We create new endpoints by adding to the schema in `/common/src/api/schema.ts`.
|
||||
|
||||
E.g. Here is a hypothetical bet schema:
|
||||
|
||||
```ts
|
||||
bet: {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
returns: {} as CandidateBet & { betId: string },
|
||||
props: z
|
||||
.object({
|
||||
contractId: z.string(),
|
||||
amount: z.number().gte(1),
|
||||
replyToCommentId: z.string().optional(),
|
||||
limitProb: z.number().gte(0.01).lte(0.99).optional(),
|
||||
expiresAt: z.number().optional(),
|
||||
// Used for binary and new multiple choice contracts (cpmm-multi-1).
|
||||
outcome: z.enum(['YES', 'NO']).default('YES'),
|
||||
//Multi
|
||||
answerId: z.string().optional(),
|
||||
dryRun: z.boolean().optional(),
|
||||
})
|
||||
.strict(),
|
||||
}
|
||||
```
|
||||
|
||||
Then, we define the bet endpoint in `backend/api/src/place-bet.ts`
|
||||
|
||||
```ts
|
||||
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],
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
And finally, you need to register the handler in `backend/api/src/routes.ts`
|
||||
|
||||
```ts
|
||||
import { placeBet } from './place-bet'
|
||||
...
|
||||
|
||||
const handlers = {
|
||||
bet: placeBet,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
We have two ways to access our postgres database.
|
||||
|
||||
```ts
|
||||
import {db} from 'web/lib/supabase/db'
|
||||
|
||||
db.from('profiles').select('*').eq('user_id', userId)
|
||||
```
|
||||
|
||||
and
|
||||
|
||||
```ts
|
||||
```typescript
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
pg.oneOrNone<Row<'profiles'>>('select * from profiles where user_id = $1', [userId])
|
||||
const user = await pg.oneOrNone<User>('SELECT * FROM users WHERE username = $1', [username])
|
||||
```
|
||||
|
||||
The supabase client just uses the supabase client library, which is a wrapper around postgREST. It allows us to query and update the database directly from the frontend.
|
||||
**Frontend (Supabase client):**
|
||||
|
||||
`createSupabaseDirectClient` is used on the backend. it lets us specify sql strings to run directly on our database, using the pg-promise library. The client (code in web) does not have permission to do this.
|
||||
```typescript
|
||||
import {db} from 'web/lib/supabase/db'
|
||||
|
||||
Another example using the direct client:
|
||||
const {data} = await db.from('profiles').select('*').eq('user_id', userId)
|
||||
```
|
||||
|
||||
```ts
|
||||
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],
|
||||
)
|
||||
return res.map((r) => r.user_id as string)
|
||||
### Translation
|
||||
|
||||
```typescript
|
||||
import {useT} from 'web/lib/locale'
|
||||
|
||||
function MyComponent() {
|
||||
const t = useT()
|
||||
|
||||
return <h1>{t('welcome', 'Welcome to Compass'
|
||||
)
|
||||
}
|
||||
</h1>
|
||||
}
|
||||
```
|
||||
|
||||
(you may notice we write sql in lowercase)
|
||||
Translation files are in `common/messages/` (en.json, fr.json, de.json).
|
||||
|
||||
We have a few helper functions for updating and inserting data into the database.
|
||||
### Backend Endpoints
|
||||
|
||||
```ts
|
||||
import {
|
||||
buikInsert,
|
||||
bulkUpdate,
|
||||
bulkUpdateData,
|
||||
bulkUpsert,
|
||||
insert,
|
||||
update,
|
||||
updateData,
|
||||
} from 'shared/supabase/utils'
|
||||
|
||||
...
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
// you are encouraged to use tryCatch for these
|
||||
const { data, error } = await tryCatch(
|
||||
insert(pg, 'profiles', { user_id: auth.uid, ...body })
|
||||
)
|
||||
|
||||
if (error) throw APIError(500, 'Error creating profile: ' + error.message)
|
||||
|
||||
await update(pg, 'profiles', 'user_id', { user_id: auth.uid, age: 99 })
|
||||
|
||||
await updateData(pg, 'private_users', { id: userId, notifications: { ... } })
|
||||
```
|
||||
|
||||
The sqlBuilder from `shared/supabase/sql-builder.ts` can be used to construct SQL queries with re-useable parts. All it does is sanitize and output sql query strings. It has several helper functions including:
|
||||
|
||||
- `select`: Specifies the columns to select
|
||||
- `from`: Specifies the table to query
|
||||
- `where`: Adds WHERE clauses
|
||||
- `orderBy`: Specifies the order of results
|
||||
- `limit`: Limits the number of results
|
||||
- `renderSql`: Combines all parts into a final SQL string
|
||||
|
||||
Example usage:
|
||||
1. Define schema in `common/src/api/schema.ts`:
|
||||
|
||||
```typescript
|
||||
const query = renderSql(
|
||||
select('distinct user_id'),
|
||||
from('contract_bets'),
|
||||
where('contract_id = ${id}', {id}),
|
||||
orderBy('created_time desc'),
|
||||
limitValue != null && limit(limitValue),
|
||||
)
|
||||
|
||||
const res = await pg.manyOrNone(query)
|
||||
'get-user-and-profile'
|
||||
:
|
||||
{
|
||||
method: 'GET',
|
||||
authed
|
||||
:
|
||||
false,
|
||||
rateLimited
|
||||
:
|
||||
true,
|
||||
props
|
||||
:
|
||||
z.object({
|
||||
username: z.string().min(1),
|
||||
}),
|
||||
returns
|
||||
:
|
||||
{
|
||||
}
|
||||
as
|
||||
{
|
||||
user: User;
|
||||
profile: ProfileRow | null
|
||||
}
|
||||
,
|
||||
summary: 'Get user and profile data by username',
|
||||
tag
|
||||
:
|
||||
'Users',
|
||||
}
|
||||
,
|
||||
```
|
||||
|
||||
Use these functions instead of string concatenation.
|
||||
2. Create handler in `backend/api/src/`:
|
||||
|
||||
```typescript
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
|
||||
export const getUserAndProfile: APIHandler<'get-user-and-profile'> = async ({username}, _auth) => {
|
||||
const user = await getUserByUsername(username)
|
||||
if (!user) {
|
||||
throw APIErrors.notFound('User not found')
|
||||
}
|
||||
|
||||
return {user, profile}
|
||||
}
|
||||
```
|
||||
|
||||
3. Register in `backend/api/src/app.ts`:
|
||||
|
||||
```typescript
|
||||
import {getUserAndProfile} from './get-user-and-profile'
|
||||
|
||||
const handlers = {
|
||||
'get-user-and-profile': getUserAndProfile,
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Profile Options (Interests, Causes, Work)
|
||||
|
||||
Options are stored in separate tables with many-to-many relationships:
|
||||
|
||||
- `interests`, `causes`, `work` - option values
|
||||
- `profile_interests`, `profile_causes`, `profile_work` - junction tables
|
||||
|
||||
Fetch in parallel:
|
||||
|
||||
```typescript
|
||||
const [interestsRes, causesRes, workRes] = await Promise.all([
|
||||
db.from('profile_interests').select('interests(name, id)').eq('profile_id', profile.id),
|
||||
db.from('profile_causes').select('causes(name, id)').eq('profile_id', profile.id),
|
||||
db.from('profile_work').select('work(name, id)').eq('profile_id', profile.id),
|
||||
])
|
||||
```
|
||||
|
||||
### API Errors
|
||||
|
||||
```typescript
|
||||
import {APIError} from './helpers/endpoint'
|
||||
|
||||
throw APIErrors.notFound('User not found')
|
||||
throw APIErrors.badRequest('Invalid input', {field: 'email'})
|
||||
```
|
||||
|
||||
### Logging
|
||||
|
||||
- Use `debug()` from `common/logger` for development
|
||||
- Use `log` from `shared/utils` for production
|
||||
|
||||
## Testing
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Jest (unit + integration)
|
||||
yarn test
|
||||
|
||||
# Playwright (E2E)
|
||||
yarn test:e2e
|
||||
```
|
||||
|
||||
### Test Structure
|
||||
|
||||
- Unit tests: `*.unit.test.ts` in `tests/unit/`
|
||||
- Integration tests: `*.integration.test.ts` in `tests/integration/`
|
||||
- E2E tests: `*.e2e.spec.ts` in `tests/e2e/`
|
||||
|
||||
### Mocking Example
|
||||
|
||||
```typescript
|
||||
jest.mock('shared/supabase/init')
|
||||
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
const mockPg = {
|
||||
oneOrNone: jest.fn(),
|
||||
tx: jest.fn(async (cb) => cb(mockTx)),
|
||||
}
|
||||
;(createSupabaseDirectClient as jest.Mock).mockReturnValue(mockPg)
|
||||
```
|
||||
|
||||
## Important Patterns
|
||||
|
||||
### User Registration
|
||||
|
||||
- Create user + profile + options in single database transaction
|
||||
- Return full profile data from creation API
|
||||
- Don't use sleep() hacks - rely on transactional integrity
|
||||
|
||||
## Things to Avoid
|
||||
|
||||
- Don't use string concatenation for SQL queries
|
||||
- Don't add sleep() delays for "eventual consistency"
|
||||
- Don't create separate API calls when data can be batched in one transaction
|
||||
- Don't use console.log - use `debug()` or `log()`
|
||||
- Don't remove commented code
|
||||
|
||||
## Key Dependencies
|
||||
|
||||
- Node.js 20+
|
||||
- React 19
|
||||
- Next.js 16
|
||||
- Supabase (PostgreSQL)
|
||||
- Firebase (Auth, Storage)
|
||||
- Tailwind CSS
|
||||
- Jest (testing)
|
||||
- Playwright (E2E testing)
|
||||
|
||||
288
.windsurf/workflows/translate.md
Normal file
288
.windsurf/workflows/translate.md
Normal file
@@ -0,0 +1,288 @@
|
||||
---
|
||||
description: Adding Translations to an Existing File (using useT)
|
||||
---
|
||||
|
||||
## AI Assistant Workflow — Adding Translations to an Existing File (using `useT`)
|
||||
|
||||
This is **not** about adding a new language.
|
||||
This is about correctly adding new translation keys to a feature or component that already exists.
|
||||
|
||||
Follow this strictly.
|
||||
|
||||
---
|
||||
|
||||
### 1️⃣ Identify All User-Facing Strings
|
||||
|
||||
Scan the file and list every:
|
||||
|
||||
- Button label
|
||||
- Title
|
||||
- Placeholder
|
||||
- Tooltip
|
||||
- Toast message
|
||||
- Modal text
|
||||
- Validation error
|
||||
- SEO metadata
|
||||
- Empty state message
|
||||
|
||||
If a string is visible to users, it must be translated.
|
||||
|
||||
Do **not** leave inline English in JSX.
|
||||
|
||||
Bad:
|
||||
|
||||
```tsx
|
||||
<button>Delete account</button>
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```tsx
|
||||
<button>{t('settings.delete_account', 'Delete account')}</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2️⃣ Import and Initialize `useT`
|
||||
|
||||
At the top of the file:
|
||||
|
||||
```tsx
|
||||
import {useT} from 'web/lib/locale'
|
||||
```
|
||||
|
||||
Inside the component:
|
||||
|
||||
```tsx
|
||||
const t = useT()
|
||||
```
|
||||
|
||||
No exceptions.
|
||||
Do not manually access locale files.
|
||||
|
||||
For the backend, use
|
||||
|
||||
```tsx
|
||||
import {createT} from 'shared/locale'
|
||||
|
||||
const t = createT(locale)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3️⃣ Replace Hardcoded Strings
|
||||
|
||||
Wrap every string in:
|
||||
|
||||
```tsx
|
||||
t('namespace.key', 'Default English text')
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```tsx
|
||||
t('news.seo.description_general', 'All news and code updates for Compass')
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- First argument = stable translation key
|
||||
- Second argument = default English fallback
|
||||
- The English text must exactly match what you want displayed
|
||||
|
||||
---
|
||||
|
||||
### 4️⃣ Naming Convention for Keys
|
||||
|
||||
Use structured namespaces.
|
||||
|
||||
Good:
|
||||
|
||||
```
|
||||
profile.delete.confirm_title
|
||||
profile.delete.confirm_body
|
||||
settings.notifications.email_label
|
||||
events.create.submit_button
|
||||
```
|
||||
|
||||
Bad:
|
||||
|
||||
```
|
||||
delete1
|
||||
buttonText
|
||||
labelNew
|
||||
```
|
||||
|
||||
Keys must be:
|
||||
|
||||
- Hierarchical
|
||||
- Feature-scoped
|
||||
- Predictable
|
||||
- Stable (never rename casually)
|
||||
|
||||
---
|
||||
|
||||
### 5️⃣ Add the Keys to All Existing Locale Files
|
||||
|
||||
After updating the component:
|
||||
|
||||
1. Open:
|
||||
|
||||
```
|
||||
common/src/messages/fr.json
|
||||
common/src/messages/de.json
|
||||
...
|
||||
```
|
||||
|
||||
2. Add the new keys to **every language file**
|
||||
|
||||
Keys must be identical across all files.
|
||||
|
||||
Example:
|
||||
|
||||
```json
|
||||
{
|
||||
"confirm_title": "Delete account?",
|
||||
"confirm_body": "This action cannot be undone."
|
||||
}
|
||||
```
|
||||
|
||||
Then translate values for non-English files.
|
||||
|
||||
---
|
||||
|
||||
### 6️⃣ Use an LLM for Draft Translation (Correctly)
|
||||
|
||||
When translating large additions:
|
||||
|
||||
- Copy only the new JSON section
|
||||
- Ask:
|
||||
|
||||
```
|
||||
Translate the values of the JSON below to French.
|
||||
Keep all keys unchanged.
|
||||
Return valid JSON only.
|
||||
```
|
||||
|
||||
Never let the model modify keys.
|
||||
|
||||
Then manually review.
|
||||
|
||||
LLMs make mistakes:
|
||||
|
||||
- Wrong tone
|
||||
- Cultural mismatch
|
||||
- Broken JSON
|
||||
- Overly long mobile labels
|
||||
|
||||
You must verify.
|
||||
|
||||
---
|
||||
|
||||
### 7️⃣ Respect Mobile Constraints
|
||||
|
||||
Certain keys must stay short (< 10 characters):
|
||||
|
||||
```
|
||||
nav.home
|
||||
nav.messages
|
||||
nav.more
|
||||
nav.notifs
|
||||
nav.people
|
||||
```
|
||||
|
||||
If you add navigation items, enforce brevity.
|
||||
|
||||
---
|
||||
|
||||
### 8️⃣ Handle Variables Properly
|
||||
|
||||
For dynamic values:
|
||||
|
||||
```tsx
|
||||
t('events.count', '{count} events', {count})
|
||||
```
|
||||
|
||||
Make sure placeholders match across all languages.
|
||||
|
||||
Do not concatenate strings manually.
|
||||
|
||||
Bad:
|
||||
|
||||
```tsx
|
||||
'Events: ' + count
|
||||
```
|
||||
|
||||
Correct:
|
||||
|
||||
```tsx
|
||||
t('events.count', '{count} events', {count})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 9️⃣ SEO & Metadata
|
||||
|
||||
Even SEO descriptions must use translations:
|
||||
|
||||
```tsx
|
||||
<meta
|
||||
name="description"
|
||||
content={t('profile.seo.description', 'View user profiles and connect with like-minded people.')}
|
||||
/>
|
||||
```
|
||||
|
||||
Do not hardcode metadata.
|
||||
|
||||
---
|
||||
|
||||
### 10️⃣ Final Verification Checklist
|
||||
|
||||
Before committing:
|
||||
|
||||
- No visible English strings left
|
||||
- All new keys added to all locale files
|
||||
- No missing translations warnings
|
||||
- JSON is valid
|
||||
- Mobile nav labels are short
|
||||
- Variables work in all languages
|
||||
- No duplicate keys
|
||||
- Namespaces are consistent
|
||||
|
||||
---
|
||||
|
||||
### Example — Full Pattern
|
||||
|
||||
```tsx
|
||||
import {useT} from 'web/lib/locale'
|
||||
|
||||
export default function DeleteModal() {
|
||||
const t = useT()
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2>{t('profile.delete.confirm_title', 'Delete account?')}</h2>
|
||||
<p>{t('profile.delete.confirm_body', 'This action cannot be undone.')}</p>
|
||||
<button>{t('profile.delete.confirm_button', 'Delete')}</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Summary
|
||||
|
||||
When adding translations to a file:
|
||||
|
||||
1. Replace every user-visible string
|
||||
2. Use `useT()`
|
||||
3. Create structured keys
|
||||
4. Add keys to every locale file
|
||||
5. Translate values carefully
|
||||
6. Validate JSON
|
||||
7. Test UI in multiple languages
|
||||
|
||||
If you skip any of these steps, you create future maintenance debt.
|
||||
|
||||
There is no shortcut.
|
||||
114
AGENTS.md
Normal file
114
AGENTS.md
Normal file
@@ -0,0 +1,114 @@
|
||||
# AGENTS.md - AI Assistant Guidelines for Compass
|
||||
|
||||
This file provides guidance for AI assistants working on the Compass codebase.
|
||||
|
||||
## Project Overview
|
||||
|
||||
Compass (compassmeet.com) is a transparent dating platform for forming deep, authentic 1-on-1 connections. Built with Next.js, React, Supabase, Firebase, and Google Cloud.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
/web # Next.js frontend (React, Tailwind CSS)
|
||||
/backend/api # Express.js REST API
|
||||
/backend/shared # Shared backend utilities
|
||||
/backend/email # Email functions
|
||||
/common # Shared types and utilities between frontend/backend
|
||||
/supabase # Database schema and migrations
|
||||
/android # Android mobile app
|
||||
```
|
||||
|
||||
## Key Conventions
|
||||
|
||||
### Database Access
|
||||
|
||||
- Use `createSupabaseDirectClient()` for backend SQL queries (pg-promise)
|
||||
- Use Supabase JS client (`db.from('table')`) for frontend queries
|
||||
- Never use string concatenation for SQL - use parameterized queries
|
||||
|
||||
### API Development
|
||||
|
||||
1. Add endpoint schema to `common/src/api/schema.ts`
|
||||
2. Create handler in `backend/api/src/`
|
||||
3. Register in `backend/api/src/app.ts`
|
||||
|
||||
### Component Patterns
|
||||
|
||||
- Export main component at top of file
|
||||
- Name component same as file (e.g., `profile-card.tsx` → `ProfileCard`)
|
||||
- Use smaller, composable components over large ones
|
||||
|
||||
### Internationalization
|
||||
|
||||
- Translation files in `common/messages/` (en.json, fr.json, de.json)
|
||||
- Use `useT()` hook: `t('key', 'fallback')`
|
||||
|
||||
### Testing
|
||||
|
||||
- Unit tests: `*.unit.test.ts` in package `tests/unit/`
|
||||
- Mock external dependencies (DB, APIs, time)
|
||||
- Use `jest.mock()` at top of test files
|
||||
|
||||
### Profile System
|
||||
|
||||
- Profile fields stored in `profiles` table
|
||||
- Options (interests, causes, work) stored in separate tables with many-to-many relationship
|
||||
- Always fetch profile options in parallel using Promise.all
|
||||
|
||||
### User Registration Flow
|
||||
|
||||
1. Create user + profile + options in single transaction
|
||||
2. Never use sleep() hacks - rely on transactional integrity
|
||||
3. Return full profile data from creation API
|
||||
|
||||
### Important Patterns
|
||||
|
||||
#### Frontend API calls (server-side):
|
||||
|
||||
```typescript
|
||||
const result = await api('endpoint-name', {props})
|
||||
```
|
||||
|
||||
#### Frontend API calls (client-side):
|
||||
|
||||
```typescript
|
||||
const {data} = useAPIGetter('endpoint-name', {props})
|
||||
```
|
||||
|
||||
#### Translation:
|
||||
|
||||
```typescript
|
||||
const t = useT()
|
||||
return <div>{t('key', 'Default text')}</div>
|
||||
```
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Adding a profile field
|
||||
|
||||
1. Add column to `profiles` table via migration
|
||||
2. Add to schema in `common/src/api/schema.ts`
|
||||
3. Update frontend forms/components
|
||||
|
||||
### Adding translations
|
||||
|
||||
1. Add key to `common/messages/en.json`
|
||||
2. Add translations to `fr.json`, `de.json`, etc.
|
||||
|
||||
## Things to Avoid
|
||||
|
||||
- Don't use string concatenation for SQL queries
|
||||
- Don't add sleep() delays for "eventual consistency" - fix at DB level
|
||||
- Don't create separate API calls when data can be batched in one transaction
|
||||
- Don't use console.log - use `debug()` from `common/logger`
|
||||
|
||||
## Key Dependencies
|
||||
|
||||
- Node.js 20+
|
||||
- React 19
|
||||
- Next.js 16
|
||||
- Supabase (PostgreSQL)
|
||||
- Firebase (Auth, Storage)
|
||||
- Tailwind CSS
|
||||
- Jest (testing)
|
||||
- Playwright (E2E testing)
|
||||
533
CONTRIBUTING.md
533
CONTRIBUTING.md
@@ -1,128 +1,493 @@
|
||||
# Contributing to This Repository
|
||||
# Contributing to Compass
|
||||
|
||||
We welcome pull requests, but only if they meet the project's quality and design standards. Follow the process below precisely to avoid wasting time—yours or ours.
|
||||
Thank you for your interest in contributing to Compass! This document provides comprehensive guidelines for contributing
|
||||
to this open-source project.
|
||||
|
||||
## Prerequisites
|
||||
## Table of Contents
|
||||
|
||||
- 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.)
|
||||
- [Code of Conduct](#code-of-conduct)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Development Environment](#development-environment)
|
||||
- [Project Structure](#project-structure)
|
||||
- [Coding Standards](#coding-standards)
|
||||
- [Making Changes](#making-changes)
|
||||
- [Testing](#testing)
|
||||
- [Pull Request Guidelines](#pull-request-guidelines)
|
||||
- [Commit Message Guidelines](#commit-message-guidelines)
|
||||
- [Documentation](#documentation)
|
||||
- [Questions and Support](#questions-and-support)
|
||||
|
||||
## Fork & Clone
|
||||
## Code of Conduct
|
||||
|
||||
1. **Fork the repository** using the GitHub UI.
|
||||
2. **Clone your fork** locally:
|
||||
Please read and follow our [Code of Conduct](./CODE_OF_CONDUCT.md). We are committed to providing a welcoming and
|
||||
inclusive environment for all contributors.
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Before contributing, ensure you have the following installed:
|
||||
|
||||
- **Node.js** 20.x or later
|
||||
- **Yarn** 1.x (classic)
|
||||
- **Git**
|
||||
- **Docker** (optional, for isolated development)
|
||||
|
||||
### Fork and Clone
|
||||
|
||||
1. Fork the [repository](https://github.com/CompassConnections/Compass) on GitHub
|
||||
2. Clone your fork:
|
||||
```bash
|
||||
git clone https://github.com/your-username/Compass.git
|
||||
cd your-fork
|
||||
|
||||
git clone https://github.com/<your-username>/Compass.git
|
||||
cd Compass
|
||||
```
|
||||
|
||||
3. **Add the upstream remote**:
|
||||
|
||||
3. Add the upstream remote:
|
||||
```bash
|
||||
git remote add upstream https://github.com/CompassConnections/Compass.git
|
||||
```
|
||||
|
||||
## Create a New Branch
|
||||
|
||||
Never work on `main` or `master`.
|
||||
### Install Dependencies
|
||||
|
||||
```bash
|
||||
git checkout -b fix/brief-but-specific-description
|
||||
yarn install --frozen-lockfile
|
||||
```
|
||||
|
||||
Use a clear, descriptive branch name. Avoid vague names like `patch-1`.
|
||||
## Development Environment
|
||||
|
||||
## Stay Updated
|
||||
|
||||
Before you start, make sure your fork is up to date:
|
||||
### Running the Development Server
|
||||
|
||||
```bash
|
||||
git fetch upstream
|
||||
git checkout main
|
||||
git merge upstream/main
|
||||
yarn dev
|
||||
```
|
||||
|
||||
Then rebase your feature branch if needed:
|
||||
Visit http://localhost:3000 to see the application.
|
||||
|
||||
### Isolated Development (Recommended)
|
||||
|
||||
For full isolation with local Supabase and Firebase emulators:
|
||||
|
||||
```bash
|
||||
git checkout fix/your-feature
|
||||
git rebase main
|
||||
yarn dev:isolated
|
||||
```
|
||||
|
||||
## Make Atomic Commits
|
||||
Benefits:
|
||||
|
||||
Each commit should represent a single logical change. Follow this format:
|
||||
- No conflicts with other contributors
|
||||
- Works offline
|
||||
- Faster database queries
|
||||
- Free to reset and reseed data
|
||||
|
||||
```text
|
||||
type(scope): concise description
|
||||
Requirements:
|
||||
|
||||
body explaining what and why, if necessary
|
||||
- Docker (~500MB)
|
||||
- Supabase CLI
|
||||
- Java 21+ (for Firebase emulators)
|
||||
- Firebase CLI
|
||||
|
||||
See the [README](./README.md) for detailed setup instructions.
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
yarn test
|
||||
|
||||
# Run tests with coverage
|
||||
yarn test:coverage
|
||||
|
||||
# Run tests in watch mode
|
||||
yarn test:watch
|
||||
|
||||
# Run E2E tests
|
||||
yarn test:e2e
|
||||
```
|
||||
|
||||
### Linting and Type Checking
|
||||
|
||||
```bash
|
||||
# Lint all packages
|
||||
yarn lint
|
||||
|
||||
# Fix linting issues
|
||||
yarn lint-fix
|
||||
|
||||
# Type check all packages
|
||||
yarn typecheck
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
This is a Yarn workspaces monorepo with the following packages:
|
||||
|
||||
```
|
||||
Compass/
|
||||
├── web/ # Next.js web application
|
||||
│ ├── components/ # React components
|
||||
│ ├── hooks/ # Custom React hooks
|
||||
│ ├── lib/ # Utilities and services
|
||||
│ ├── pages/ # Next.js pages
|
||||
│ └── messages/ # Internationalization files
|
||||
├── backend/
|
||||
│ ├── api/ # Express API server
|
||||
│ ├── shared/ # Shared backend utilities
|
||||
│ ├── email/ # React email templates
|
||||
│ └── scripts/ # Database migration scripts
|
||||
├── common/ # Shared TypeScript types and utilities
|
||||
├── supabase/ # Database migrations and config
|
||||
├── android/ # Capacitor Android app
|
||||
└── docs/ # Project documentation
|
||||
```
|
||||
|
||||
### Key Technologies
|
||||
|
||||
| Layer | Technology |
|
||||
| -------- | -------------------------------- |
|
||||
| Frontend | Next.js 16, React 19, TypeScript |
|
||||
| Styling | Tailwind CSS |
|
||||
| Backend | Express.js, Node.js |
|
||||
| Database | PostgreSQL (Supabase) |
|
||||
| Auth | Firebase Auth |
|
||||
| Storage | Firebase Storage |
|
||||
| Mobile | Capacitor (Android) |
|
||||
| Testing | Jest, Playwright |
|
||||
|
||||
## Coding Standards
|
||||
|
||||
### TypeScript
|
||||
|
||||
- Use strict TypeScript typing
|
||||
- Avoid `any` type; use `unknown` when necessary
|
||||
- Prefer interfaces over types for object shapes
|
||||
- Use `const` assertions where appropriate
|
||||
|
||||
### React Components
|
||||
|
||||
- Use functional components with hooks
|
||||
- Name components after their file name
|
||||
- Export primary component at the top of the file
|
||||
- Use composition over inheritance
|
||||
- Keep components small and focused
|
||||
|
||||
Example component structure:
|
||||
|
||||
```tsx
|
||||
import clsx from 'clsx'
|
||||
import {useState} from 'react'
|
||||
|
||||
interface ProfileCardProps {
|
||||
name: string
|
||||
age: number
|
||||
onSelect?: (id: string) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function ProfileCard({name, age, onSelect, className}: ProfileCardProps) {
|
||||
const [selected, setSelected] = useState(false)
|
||||
|
||||
const handleClick = () => {
|
||||
setSelected(!selected)
|
||||
onSelect?.(name)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={clsx('card', selected && 'selected', className)}>
|
||||
<h3>
|
||||
{name}, {age}
|
||||
</h3>
|
||||
<button onClick={handleClick}>Select</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Naming Conventions
|
||||
|
||||
- **Files**: kebab-case (`profile-card.tsx`)
|
||||
- **Components**: PascalCase (`ProfileCard`)
|
||||
- **Hooks**: camelCase with `use` prefix (`useUserProfile`)
|
||||
- **Constants**: SCREAMING_SNAKE_CASE
|
||||
- **Types/Interfaces**: PascalCase
|
||||
|
||||
### Import Order
|
||||
|
||||
Run `yarn lint-fix` to automatically sort imports:
|
||||
|
||||
1. External libraries (React, Next.js, etc.)
|
||||
2. Internal packages (`common/`, `shared/`)
|
||||
3. Relative imports (`../`, `./`)
|
||||
4. Type imports
|
||||
|
||||
### Error Handling
|
||||
|
||||
- Use try-catch for async operations
|
||||
- Create custom error types for API errors
|
||||
- Implement error boundaries for React components
|
||||
- Log errors with appropriate context
|
||||
|
||||
Example:
|
||||
|
||||
```typescript
|
||||
import {APIError} from './errors'
|
||||
|
||||
try {
|
||||
const result = await api('endpoint', params)
|
||||
return result
|
||||
} catch (err) {
|
||||
if (err instanceof APIError) {
|
||||
logger.error('API error', {status: err.status, message: err.message})
|
||||
} else {
|
||||
logger.error('Unexpected error', err)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
```
|
||||
|
||||
### Accessibility
|
||||
|
||||
- Use semantic HTML elements
|
||||
- Include ARIA labels where appropriate
|
||||
- Ensure keyboard navigation works
|
||||
- Use the `SkipLink` component for main content
|
||||
- Announce dynamic content changes with `useLiveRegion`
|
||||
|
||||
```tsx
|
||||
import {useLiveRegion} from 'web/components/live-region'
|
||||
|
||||
function MyComponent() {
|
||||
const {announce} = useLiveRegion()
|
||||
|
||||
const handleAction = () => {
|
||||
// Action completed
|
||||
announce('Action successful', 'polite')
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Making Changes
|
||||
|
||||
### Creating a Branch
|
||||
|
||||
Never work directly on `main`. Create a new branch:
|
||||
|
||||
```bash
|
||||
git checkout -b type/short-description
|
||||
```
|
||||
|
||||
Branch types:
|
||||
|
||||
- `feat/` - New features
|
||||
- `fix/` - Bug fixes
|
||||
- `docs/` - Documentation
|
||||
- `refactor/` - Code refactoring
|
||||
- `test/` - Adding/updating tests
|
||||
- `chore/` - Maintenance tasks
|
||||
|
||||
### Making Commits
|
||||
|
||||
Keep commits atomic and descriptive:
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "feat(profiles): add compatibility score display
|
||||
|
||||
- Added compatibility score calculation
|
||||
- Display score on profile cards
|
||||
- Added tests for scoring algorithm"
|
||||
```
|
||||
|
||||
See [Commit Message Guidelines](#commit-message-guidelines) for details.
|
||||
|
||||
### Keeping Your Fork Updated
|
||||
|
||||
```bash
|
||||
# Fetch latest from upstream
|
||||
git fetch upstream
|
||||
|
||||
# Update main branch
|
||||
git checkout main
|
||||
git merge upstream/main
|
||||
|
||||
# Rebase your feature branch
|
||||
git checkout feat/your-feature
|
||||
git rebase main
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Writing Tests
|
||||
|
||||
#### Unit Tests
|
||||
|
||||
Place tests in `tests/unit/` within each package:
|
||||
|
||||
```typescript
|
||||
// web/tests/unit/my-function.test.ts
|
||||
import {myFunction} from '../my-function'
|
||||
|
||||
describe('myFunction', () => {
|
||||
it('should return correct output', () => {
|
||||
expect(myFunction('input')).toBe('expected')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
#### Integration Tests
|
||||
|
||||
Place in `tests/integration/`:
|
||||
|
||||
```typescript
|
||||
// web/tests/integration/api.test.ts
|
||||
import {render, screen} from '@testing-library/react'
|
||||
import {MyComponent} from '../MyComponent'
|
||||
|
||||
describe('MyComponent', () => {
|
||||
it('renders correctly', () => {
|
||||
render(<MyComponent / >)
|
||||
expect(screen.getByText('Hello')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
#### E2E Tests
|
||||
|
||||
Place in `tests/e2e/` at the root:
|
||||
|
||||
```typescript
|
||||
// tests/e2e/web/specs/onboarding.spec.ts
|
||||
import {test, expect} from '@playwright/test'
|
||||
|
||||
test('onboarding flow', async ({page}) => {
|
||||
await page.goto('/signup')
|
||||
await page.fill('[name="email"]', 'test@example.com')
|
||||
await page.click('button[type="submit"]')
|
||||
await expect(page).toHaveURL('/onboarding')
|
||||
})
|
||||
```
|
||||
|
||||
### Running Specific Tests
|
||||
|
||||
```bash
|
||||
# Run unit tests for web
|
||||
yarn workspace web test
|
||||
|
||||
# Run tests matching pattern
|
||||
yarn test --testPathPattern="profile"
|
||||
|
||||
# Run E2E tests
|
||||
yarn test:e2e
|
||||
```
|
||||
|
||||
### Test Coverage
|
||||
|
||||
Aim for meaningful test coverage. Focus on:
|
||||
|
||||
- Business logic
|
||||
- User interactions
|
||||
- Error handling
|
||||
- Edge cases
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
### Before Submitting
|
||||
|
||||
1. **Run all tests**: `yarn test`
|
||||
2. **Run linter**: `yarn lint`
|
||||
3. **Run type check**: `yarn typecheck`
|
||||
4. **Update documentation** if needed
|
||||
5. **Rebase on main** if necessary
|
||||
|
||||
### Pull Request Format
|
||||
|
||||
**Title**: Clear, descriptive title
|
||||
|
||||
**Description**:
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
|
||||
Brief description of changes
|
||||
|
||||
## Changes
|
||||
|
||||
- Added compatibility score to profile cards
|
||||
- Updated search algorithm for better results
|
||||
|
||||
## Testing
|
||||
|
||||
- Added unit tests for scoring algorithm
|
||||
- Tested manually with synthetic data
|
||||
|
||||
## Screenshots (if UI changes)
|
||||
```
|
||||
|
||||
### PR Checklist
|
||||
|
||||
- [ ] Code follows style guidelines
|
||||
- [ ] Tests added/updated and passing
|
||||
- [ ] Documentation updated
|
||||
- [ ] No console.log statements (except debugging)
|
||||
- [ ] No debug code left behind
|
||||
|
||||
### Review Process
|
||||
|
||||
1. Maintainers review within 48 hours
|
||||
2. Address feedback promptly
|
||||
3. Do not open new PRs for changes - update existing one
|
||||
4. Squash commits before merging
|
||||
|
||||
## Commit Message Guidelines
|
||||
|
||||
Follow [Conventional Commits](https://www.conventionalcommits.org/):
|
||||
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
|
||||
[optional body]
|
||||
|
||||
[optional footer]
|
||||
```
|
||||
|
||||
### Types
|
||||
|
||||
- `feat`: New feature
|
||||
- `fix`: Bug fix
|
||||
- `docs`: Documentation
|
||||
- `style`: Formatting
|
||||
- `refactor`: Code restructuring
|
||||
- `test`: Tests
|
||||
- `chore`: Maintenance
|
||||
|
||||
### Examples
|
||||
|
||||
```text
|
||||
fix(api): handle 500 error on invalid payload
|
||||
feat(profiles): add compatibility scoring algorithm
|
||||
fix(api): handle rate limiting gracefully
|
||||
docs(readme): update installation instructions
|
||||
refactor(auth): simplify token refresh logic
|
||||
test(profiles): add unit tests for scoring
|
||||
```
|
||||
|
||||
Types include: `feat`, `fix`, `docs`, `refactor`, `test`, `chore`.
|
||||
## Documentation
|
||||
|
||||
## Test Everything
|
||||
### Updating Documentation
|
||||
|
||||
If the project has tests, run them. If it doesn’t, write some. Do **not** submit code that hasn't been tested.
|
||||
- Update relevant README files
|
||||
- Add JSDoc comments to complex functions
|
||||
- Update the `/docs` folder for architectural changes
|
||||
|
||||
```bash
|
||||
# Example for Node.js
|
||||
npm test
|
||||
```
|
||||
### API Documentation
|
||||
|
||||
No exceptions. If you don't validate your changes, your PR will be closed.
|
||||
API docs are auto-generated and available at:
|
||||
|
||||
## Lint & Format
|
||||
- Production: https://api.compassmeet.com
|
||||
- Local: http://localhost:8088 (when running locally)
|
||||
|
||||
Ensure code matches the project style. If the repo uses a linter or formatter, run them:
|
||||
## Questions and Support
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm run format
|
||||
```
|
||||
- **Discord**: https://discord.gg/8Vd7jzqjun
|
||||
- **Email**: hello@compassmeet.com
|
||||
- **GitHub Issues**: For bug reports and feature requests
|
||||
|
||||
Or whatever command is defined in the repo.
|
||||
---
|
||||
|
||||
## Write a Good Pull Request
|
||||
|
||||
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)
|
||||
|
||||
## 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.
|
||||
|
||||
## 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
|
||||
|
||||
## Security Issues
|
||||
|
||||
Do **not** open public issues for security vulnerabilities. Email the development team instead.
|
||||
|
||||
## License
|
||||
|
||||
By contributing, you agree that your code will be licensed under the same license as the rest of the project.
|
||||
Thank you for contributing to Compass! Together we're building a platform for meaningful connections.
|
||||
|
||||
85
README.md
85
README.md
@@ -1,6 +1,7 @@
|
||||

|
||||
[](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/cd-android.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)
|
||||
@@ -8,7 +9,8 @@
|
||||
|
||||
# Compass
|
||||
|
||||
This repository contains the source code for [Compass](https://compassmeet.com) — a transparent platform for forming deep, authentic 1-on-1 connections with clarity and efficiency.
|
||||
This repository contains the source code for [Compass](https://compassmeet.com) — a transparent platform for forming
|
||||
deep, authentic 1-on-1 connections with clarity and efficiency.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -16,23 +18,35 @@ This repository contains the source code for [Compass](https://compassmeet.com)
|
||||
- Radically transparent: user base fully searchable
|
||||
- Free, ad-free, not for profit (supported by donations)
|
||||
- Created, hosted, maintained, and moderated by volunteers
|
||||
- Open source
|
||||
- Open-source
|
||||
- Democratically governed
|
||||
|
||||
You can find a lot of interesting info in the [About page](https://www.compassmeet.com/about) and the [FAQ](https://www.compassmeet.com/faq) as well.
|
||||
A detailed description of the early vision is also available in this [blog post](https://martinbraquet.com/meeting-rational) (you can disregard the parts about rationality, as Compass shifted to a more general audience).
|
||||
You can find a lot of interesting info in the [About page](https://www.compassmeet.com/about) and
|
||||
the [FAQ](https://www.compassmeet.com/faq) as well.
|
||||
A detailed description of the early vision is also available in
|
||||
this [blog post](https://martinbraquet.com/meeting-rational) (you can disregard the parts about rationality, as Compass
|
||||
shifted to a more general audience).
|
||||
|
||||
**We can’t do this alone.** Whatever your skills—coding, design, writing, moderation, marketing, or even small donations—you can make a real difference. [Contribute](https://www.compassmeet.com/support) in any way you can and help our community thrive!
|
||||
**We can’t do this alone.** Whatever your skills—coding, design, writing, moderation, marketing, or even small
|
||||
donations—you can make a real difference. [Contribute](https://www.compassmeet.com/support) in any way you can and help
|
||||
our community thrive!
|
||||
|
||||

|
||||
|
||||
## To Do
|
||||
|
||||
No contribution is too small—whether it’s changing a color, resizing a button, tweaking a font, or improving wording. Bigger contributions like adding new profile fields, building modules, or improving onboarding are equally welcome. The goal is to make the platform better step by step, and every improvement counts. If you see something that could be clearer, smoother, or more engaging, **please jump in**!
|
||||
No contribution is too small—whether it’s changing a color, resizing a button, tweaking a font, or improving wording.
|
||||
Bigger contributions like adding new profile fields, building modules, or improving onboarding are equally welcome. The
|
||||
goal is to make the platform better step by step, and every improvement counts. If you see something that could be
|
||||
clearer, smoother, or more engaging, **please jump in**!
|
||||
|
||||
The complete, official list of tasks is available [here on ClickUp](https://sharing.clickup.com/90181043445/l/h/6-901810339879-1/bbfd32f4f4bf64b). If you are working on one task, just assign it to yourself and move its status to "in progress". If there is also a GitHub issue for that task, assign it to yourself as well.
|
||||
The complete, official list of tasks is
|
||||
available [here on ClickUp](https://sharing.clickup.com/90181043445/l/h/6-901810339879-1/bbfd32f4f4bf64b). If you are
|
||||
working on one task, just assign it to yourself and move its status to "in progress". If there is also a GitHub issue
|
||||
for that task, assign it to yourself as well.
|
||||
|
||||
To have edit access to the ClickUp workspace, you need an admin to manually give you permission (one time thing). To do so, use your preferred option:
|
||||
To have edit access to the ClickUp workspace, you need an admin to manually give you permission (one time thing). To do
|
||||
so, use your preferred option:
|
||||
|
||||
- Ask or DM an admin on [Discord](https://discord.gg/8Vd7jzqjun)
|
||||
- Email hello@compassmeet.com
|
||||
@@ -46,7 +60,8 @@ a.t.901810339879.u-276866260.b847aba1-2709-4f17-b4dc-565a6967c234@tasks.clickup.
|
||||
|
||||
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).
|
||||
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).
|
||||
|
||||
- [x] Authentication (user/password and Google Sign In)
|
||||
- [x] Set up PostgreSQL in Production with supabase
|
||||
@@ -64,11 +79,12 @@ Here is a tailored selection of things that would be very useful. If you want to
|
||||
- [ ] Add modules to learn more about each other (personality test, conflict style, love languages, etc.)
|
||||
- [ ] Add modules to improve interpersonal skills (active listening, nonviolent communication, etc.)
|
||||
- [ ] Add calendar integration and scheduling
|
||||
- [ ] Add events (group calls, in-person meetups, etc.)
|
||||
- [x] Add events (group calls, in-person meetups, etc.)
|
||||
|
||||
#### Secondary To Do
|
||||
|
||||
Everything is open to anyone for collaboration, but the following ones are particularly easy to do for first-time contributors.
|
||||
Everything is open to anyone for collaboration, but the following ones are particularly easy to do for first-time
|
||||
contributors.
|
||||
|
||||
- [x] Clean up learn more page
|
||||
- [x] Add dark theme
|
||||
@@ -102,11 +118,13 @@ The web app is coded in Typescript using React as front-end. It includes:
|
||||
|
||||
## Development
|
||||
|
||||
Below are the steps to contribute. If you have any trouble or questions, please don't hesitate to open an issue or contact us on [Discord](https://discord.gg/8Vd7jzqjun)! We're responsive and happy to help.
|
||||
Below are the steps to contribute. If you have any trouble or questions, please don't hesitate to open an issue or
|
||||
contact us on [Discord](https://discord.gg/8Vd7jzqjun)! We're responsive and happy to help.
|
||||
|
||||
### Installation
|
||||
|
||||
Fork the [repo](https://github.com/CompassConnections/Compass) on GitHub (button in top right). Then, clone your repo and navigating into it:
|
||||
Fork the [repo](https://github.com/CompassConnections/Compass) on GitHub (button in top right). Then, clone your repo
|
||||
and navigating into it:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/<your-username>/Compass.git
|
||||
@@ -143,9 +161,11 @@ Start the development server:
|
||||
yarn dev
|
||||
```
|
||||
|
||||
Once the server is running, visit http://localhost:3000 to start using the app. You can sign up and visit the profiles; you should see a few synthetic profiles.
|
||||
Once the server is running, visit http://localhost:3000 to start using the app. You can sign up and visit the profiles;
|
||||
you should see a few synthetic profiles.
|
||||
|
||||
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!
|
||||
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
|
||||
|
||||
@@ -234,25 +254,31 @@ looks and feels like the real thing.
|
||||
|
||||
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:
|
||||
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)
|
||||
- Components tab to see the React component tree and props (you need to install
|
||||
the [React Developer Tools](https://react.dev/learn/react-developer-tools) extension)
|
||||
- Console tab for errors and logs
|
||||
- Network tab to see the requests and responses
|
||||
- Storage tab to see cookies and local storage
|
||||
|
||||
You can also add `console.log()` statements in the code.
|
||||
|
||||
If you are new to Typescript or the open-source space, you could start with small changes, such as tweaking some web components or improving wording in some pages. You can find those files in `web/public/md/`.
|
||||
If you are new to Typescript or the open-source space, you could start with small changes, such as tweaking some web
|
||||
components or improving wording in some pages. You can find those files in `web/public/md/`.
|
||||
|
||||
##### Resources
|
||||
|
||||
There is a lof of documentation in the [docs](docs) folder and across the repo, namely:
|
||||
There is a lot 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 or languages.
|
||||
- [TESTING.md](docs/TESTING.md) for how to write tests.
|
||||
- [PERFORMANCE_OPTIMIZATION.md](docs/PERFORMANCE_OPTIMIZATION.md) for frontend, backend, and database performance best practices.
|
||||
- [DATABASE_CONNECTION_POOLING.md](docs/DATABASE_CONNECTION_POOLING.md) for database connection management and troubleshooting.
|
||||
- [TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) for resolving common development issues.
|
||||
- [web](web) for the web.
|
||||
- [backend/api](backend/api) for the backend API.
|
||||
- [android](android) for the Android app.
|
||||
@@ -290,19 +316,28 @@ Finally, open a Pull Request on GitHub from your `fork/<branch-name>` → `Compa
|
||||
|
||||
### Environment Variables
|
||||
|
||||
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.
|
||||
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.
|
||||
- Email, analytics, and location services, otherwise anyone could use the service plans Compass paid for and run up the
|
||||
bill.
|
||||
|
||||
That's why we separate all those services between production and development environments, so that you can code freely without impacting the functioning of the deployed platform.
|
||||
Contributors should use the default keys for local development. Production uses a separate environment with stricter rules and private keys that are not shared.
|
||||
That's why we separate all those services between production and development environments, so that you can code freely
|
||||
without impacting the functioning of the deployed platform.
|
||||
Contributors should use the default keys for local development. Production uses a separate environment with stricter
|
||||
rules and private keys that are not shared.
|
||||
|
||||
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.
|
||||
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.
|
||||
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.
|
||||
|
||||
117
SECURITY.md
117
SECURITY.md
@@ -4,8 +4,121 @@
|
||||
|
||||
| Version | Supported |
|
||||
| ------- | ------------------ |
|
||||
| 1.0.0 | :white_check_mark: |
|
||||
| 1.10.x | :white_check_mark: |
|
||||
| 1.9.x | :white_check_mark: |
|
||||
| < 1.9.0 | :x: |
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Contact the development team at hello@compassmeet.com to report a vulnerability. You should receive updates within a week.
|
||||
If you discover a security vulnerability within Compass, please send an email to hello@compassmeet.com. All security vulnerabilities will be promptly addressed.
|
||||
|
||||
Please do not publicly disclose the vulnerability until it has been resolved.
|
||||
|
||||
## Security Practices
|
||||
|
||||
Compass takes security seriously and implements several best practices to protect user data and ensure application integrity.
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
- **Firebase Authentication**: User authentication is handled by Firebase Auth, which provides industry-standard security for user credentials
|
||||
- **JWT Tokens**: Secure token-based authentication for API access
|
||||
- **Role-Based Access Control**: Different permission levels for users, moderators, and administrators
|
||||
- **Session Management**: Secure session handling with automatic timeout
|
||||
|
||||
### Data Protection
|
||||
|
||||
- **Encryption at Rest**: Sensitive data is encrypted in the database
|
||||
- **Encryption in Transit**: All communications use HTTPS/TLS encryption
|
||||
- **Environment Variables**: Secrets are managed through secure environment variable configuration
|
||||
- **Data Minimization**: Only necessary data is collected and stored
|
||||
|
||||
### Input Validation
|
||||
|
||||
- **Zod Validation**: Strong type checking and validation for all API inputs
|
||||
- **Sanitization**: Input sanitization to prevent injection attacks
|
||||
- **Rate Limiting**: Protection against brute force and denial of service attacks
|
||||
|
||||
### API Security
|
||||
|
||||
- **CORS Configuration**: Restricted cross-origin resource sharing policies
|
||||
- **Rate Limiting**: Per-endpoint rate limiting to prevent abuse
|
||||
- **Authentication Middleware**: All protected endpoints require valid authentication
|
||||
- **Input Validation**: Comprehensive validation of all API inputs
|
||||
|
||||
### Database Security
|
||||
|
||||
- **Row Level Security**: Fine-grained access control at the database level
|
||||
- **Parameterized Queries**: Prevention of SQL injection attacks
|
||||
- **Audit Logging**: Tracking of database access and modifications
|
||||
- **Regular Backups**: Automated database backups for disaster recovery
|
||||
|
||||
### Third-Party Services
|
||||
|
||||
- **Firebase Security Rules**: Strict security rules for Firestore and Storage
|
||||
- **Supabase RLS**: Row-level security policies for PostgreSQL
|
||||
- **Secrets Management**: Secure storage of API keys and credentials
|
||||
|
||||
### Development Practices
|
||||
|
||||
- **Code Reviews**: All changes reviewed by multiple developers
|
||||
- **Automated Testing**: Security-focused tests integrated into CI/CD pipeline
|
||||
- **Dependency Management**: Regular updates and security scanning of dependencies
|
||||
- **Security Audits**: Periodic security assessments and penetration testing
|
||||
|
||||
## Common Security Issues and Resolutions
|
||||
|
||||
### XSS Prevention
|
||||
|
||||
- **Content Security Policy**: Strict CSP headers to prevent cross-site scripting
|
||||
- **Input Sanitization**: All user-generated content is sanitized before display
|
||||
- **Output Encoding**: Proper encoding of user data in HTML contexts
|
||||
|
||||
### CSRF Protection
|
||||
|
||||
- **SameSite Cookies**: CSRF protection through SameSite cookie attributes
|
||||
- **Anti-Forgery Tokens**: Token-based protection for state-changing operations
|
||||
|
||||
### Injection Attacks
|
||||
|
||||
- **SQL Injection**: Parameterized queries and prepared statements
|
||||
- **Command Injection**: Input validation and sanitization
|
||||
- **Script Injection**: Content Security Policy and input filtering
|
||||
|
||||
## Incident Response
|
||||
|
||||
In the event of a security incident:
|
||||
|
||||
1. **Immediate Containment**: Isolate affected systems
|
||||
2. **Investigation**: Determine scope and impact of breach
|
||||
3. **Remediation**: Apply fixes and security patches
|
||||
4. **Notification**: Inform affected users and stakeholders
|
||||
5. **Review**: Post-incident analysis and process improvement
|
||||
|
||||
## Compliance
|
||||
|
||||
Compass aims to comply with relevant data protection regulations:
|
||||
|
||||
- **GDPR**: General Data Protection Regulation compliance
|
||||
- **CCPA**: California Consumer Privacy Act compliance
|
||||
- **Data Retention**: Clear policies for data retention and deletion
|
||||
|
||||
## Third-Party Security
|
||||
|
||||
We regularly audit third-party services for:
|
||||
|
||||
- Security certifications and compliance
|
||||
- Regular security updates and patches
|
||||
- Data handling and privacy practices
|
||||
- Incident response procedures
|
||||
|
||||
## Security Contact
|
||||
|
||||
For security-related inquiries, contact:
|
||||
|
||||
- Email: hello@compassmeet.com
|
||||
- Response Time: Within 24 hours for critical issues
|
||||
- Disclosure Policy: Coordinated disclosure with 90-day timeline
|
||||
|
||||
---
|
||||
|
||||
_Last Updated: March 2026_
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
# Compass Android WebView App
|
||||
|
||||
This folder contains the source code for the Android application of Compass.
|
||||
A hybrid mobile app built with **Next.js (TypeScript)** frontend, **Firebase backend**, and wrapped as a **Capacitor WebView** for Android. In the future it may contain native code as well.
|
||||
A hybrid mobile app built with **Next.js (TypeScript)** frontend, **Firebase backend**, and wrapped as a **Capacitor
|
||||
WebView** for Android. In the future it may contain native code as well.
|
||||
|
||||
This document describes how to:
|
||||
1. Build and run the web frontend and backend locally
|
||||
2. Sync and build the Android WebView wrapper
|
||||
3. Debug, sign, and publish the APK
|
||||
|
||||
1. Build and run the web frontend and backend locally
|
||||
2. Sync and build the Android WebView wrapper
|
||||
3. Debug, sign, and publish the APK
|
||||
4. Enable Google Sign-In and push notifications
|
||||
|
||||
---
|
||||
@@ -15,7 +17,8 @@ This document describes how to:
|
||||
|
||||
The app is a Capacitor Android project that loads the local Next.js assets inside a WebView.
|
||||
|
||||
During development, it can instead load the local frontend (`http://10.0.2.2:3000`) and backend (`http://10.0.2.2:8088`).
|
||||
During development, it can instead load the local frontend (`http://10.0.2.2:3000`) and backend (
|
||||
`http://10.0.2.2:8088`).
|
||||
|
||||
Firebase handles authentication and push notifications.
|
||||
Google Sign-In is supported natively in the WebView via the Capacitor Social Login plugin.
|
||||
@@ -29,29 +32,36 @@ Project Structure
|
||||
- `AndroidManifest.xml`: The manifest file that describes essential information about the application.
|
||||
|
||||
### **Why Local Is the Default**
|
||||
|
||||
- **Performance:** Local assets load instantly, without network latency.
|
||||
- **Reliability:** Works offline or in poor connectivity environments.
|
||||
- **App Store policy compliance:** Apple and Google generally prefer that the main experience doesn’t depend on a remote site (for security, review, and performance reasons).
|
||||
- **App Store policy compliance:** Apple and Google generally prefer that the main experience doesn’t depend on a remote
|
||||
site (for security, review, and performance reasons).
|
||||
- **Version consistency:** The web bundle is versioned with the app, ensuring no breaking updates outside your control.
|
||||
|
||||
When Remote (No Local Assets) Is sometimes Used
|
||||
Loading from a **remote URL** (e.g. `https://compassmeet.com`) is **less common**, but seen in a few cases:
|
||||
|
||||
- **Internal enterprise apps** where the WebView just wraps an existing web portal.
|
||||
- **Dynamic content** or **frequent updates** where pushing a new web build every time through app stores would be too slow.
|
||||
- **Dynamic content** or **frequent updates** where pushing a new web build every time through app stores would be too
|
||||
slow.
|
||||
- To leverage the low latency of ISR and SSR.
|
||||
However, this approach requires:
|
||||
However, this approach requires:
|
||||
- Careful handling of **CORS**, **SSL**, and **login/session** persistence.
|
||||
- Compliance with **Google Play policies** (they may reject apps that are “just a webview of a website” unless there’s meaningful native integration).
|
||||
- Compliance with **Google Play policies** (they may reject apps that are “just a webview of a website” unless there’s
|
||||
meaningful native integration).
|
||||
|
||||
**A middle ground we use:**
|
||||
|
||||
- The app ships with **local assets** for core functionality.
|
||||
- The app **fetches remote content or updates** (e.g., via Capacitor Live Updates, Ionic Appflow).
|
||||
|
||||
## 2. Prerequisites
|
||||
|
||||
### Required Software
|
||||
|
||||
| Tool | Version | Purpose |
|
||||
| -------------- | ------- | ---------------------------------- |
|
||||
|----------------|---------|------------------------------------|
|
||||
| Node.js | 22+ | For building frontend/backend |
|
||||
| yarn | latest | Package manager |
|
||||
| Java | 21 | Required for Android Gradle plugin |
|
||||
@@ -60,6 +70,7 @@ However, this approach requires:
|
||||
| OpenJDK | 21 | JDK for Gradle |
|
||||
|
||||
### Environment Setup
|
||||
|
||||
```bash
|
||||
sudo apt install openjdk-21-jdk
|
||||
sudo update-alternatives --config java
|
||||
@@ -84,6 +95,7 @@ yarn build-web-view
|
||||
If you want the webview to load from your local web version of Compass, run the web app.
|
||||
|
||||
In root directory:
|
||||
|
||||
```bash
|
||||
export NEXT_PUBLIC_LOCAL_ANDROID=1
|
||||
yarn dev # or prod
|
||||
@@ -94,7 +106,8 @@ yarn dev # or prod
|
||||
|
||||
### Deployed mode
|
||||
|
||||
If you want the webview to load from the deployed web version of Compass (like at www.compassmeet.com), no web app to run.
|
||||
If you want the webview to load from the deployed web version of Compass (like at compassmeet.com), no web app to
|
||||
run.
|
||||
|
||||
---
|
||||
|
||||
@@ -108,6 +121,7 @@ cd android
|
||||
```
|
||||
|
||||
Sync web files and native plugins with Android, for offline fallback. In root:
|
||||
|
||||
```
|
||||
export NEXT_PUBLIC_LOCAL_ANDROID=1 # if running your local web Compass
|
||||
yarn build-web-view # if you made changes to web app
|
||||
@@ -116,20 +130,28 @@ npx cap sync android
|
||||
|
||||
### Load from site
|
||||
|
||||
During local development, open Android Studio project and run the app on an emulator or your physical device.
|
||||
During local development, open Android Studio project and run the app on an emulator or your physical device.
|
||||
|
||||
To use an emulator:
|
||||
|
||||
```
|
||||
npx cap open android
|
||||
```
|
||||
|
||||
To use a physical device for the local web version, you need your mobile and computer to be on the same network / Wi-Fi and point the URL (`LOCAL_BACKEND_DOMAIN` in the code) to your computer IP address (for example, `192.168.1.3:3000`). You also need to set
|
||||
To use a physical device for the local web version, you need your mobile and computer to be on the same network / Wi-Fi
|
||||
and point the URL (`LOCAL_BACKEND_DOMAIN` in the code) to your computer IP address (for example, `192.168.1.3:3000`).
|
||||
You also need to set
|
||||
|
||||
```
|
||||
export NEXT_PUBLIC_WEBVIEW_DEV_PHONE=1
|
||||
```
|
||||
Then adb install the app your phone (or simply run it from Android Studio on your phone) and the app should be loading content directly from the local code on your computer. You can make changes in the code and it will refresh instantly on the phone.
|
||||
|
||||
Then adb install the app your phone (or simply run it from Android Studio on your phone) and the app should be loading
|
||||
content directly from the local code on your computer. You can make changes in the code and it will refresh instantly on
|
||||
the phone.
|
||||
|
||||
Building the Application:
|
||||
|
||||
1. Open Android Studio.
|
||||
2. Click on "Open an existing Android Studio project".
|
||||
3. Navigate to the `android` folder in this repository and select it.
|
||||
@@ -145,7 +167,8 @@ Building the Application:
|
||||
|
||||
### From Android Studio
|
||||
|
||||
- If you want to generate a signed APK for release, go to "Build" > "Generate Signed Bundle / APK..." and follow the prompts.
|
||||
- If you want to generate a signed APK for release, go to "Build" > "Generate Signed Bundle / APK..." and follow the
|
||||
prompts.
|
||||
- Make sure to test the application thoroughly on different devices and Android versions to ensure compatibility.
|
||||
|
||||
### Debug build
|
||||
@@ -184,19 +207,26 @@ adb install -r app/build/outputs/apk/debug/app-debug.apk
|
||||
|
||||
### Release on App Stores
|
||||
|
||||
To release on the app stores, you need to submit the .aab files, which are not signed, instead of APK. Google or Apple will then sign it with their own key.
|
||||
To release on the app stores, you need to submit the .aab files, which are not signed, instead of APK. Google or Apple
|
||||
will then sign it with their own key.
|
||||
|
||||
However, it's recommended to use the GitHub Action for better version control and automation. See section below:
|
||||
`Deploy to Play Store`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Debugging
|
||||
|
||||
Client logs from the emulator on Chrome can be accessed at:
|
||||
Client logs from the emulator on Chrome can be accessed at:
|
||||
|
||||
```
|
||||
chrome://inspect/#devices
|
||||
```
|
||||
|
||||
Backend logs can be accessed from the output of `yarn prod / dev` like in the web application.
|
||||
|
||||
Java/Kotlin logs can be accessed via Android Studio's Logcat.
|
||||
|
||||
Backend logs can be accessed from the output of `yarn prod / dev` like in the web application.
|
||||
|
||||
Java/Kotlin logs can be accessed via Android Studio's Logcat.
|
||||
|
||||
```
|
||||
adb logcat | grep CompassApp
|
||||
adb logcat | grep com.compassconnections.app
|
||||
@@ -219,22 +249,29 @@ webView.setWebChromeClient(new WebChromeClient() {
|
||||
|
||||
## 10. Deploy to Play Store
|
||||
|
||||
The best way to deploy to the Play Store is to use the GitHub Action defined
|
||||
in [cd-android.yml](../.github/workflows/cd-android.yml). You
|
||||
increase the version in `android/app/build.gradle`, commit to the main branch and it will automatically build the
|
||||
release APK and upload it to the Play Store.
|
||||
|
||||
To deploy manually, follow these steps:
|
||||
|
||||
1. Sign the release APK or AAB.
|
||||
2. Verify package name matches Firebase settings (`com.compassconnections.app`).
|
||||
3. Upload to Google Play Console.
|
||||
4. Add Privacy Policy and content rating.
|
||||
5. Submit for review.
|
||||
4. Add Privacy Policy and content rating (one time).
|
||||
5. Submit for review. It takes around an hour for it to be approved and appear in the store.
|
||||
|
||||
---
|
||||
|
||||
## 11. Common Issues
|
||||
|
||||
| Problem | Cause | Fix |
|
||||
| -------------------------------------- | -------------------------------------- | ------------------------------------------------------------------- |
|
||||
| `INSTALL_FAILED_UPDATE_INCOMPATIBLE` | Old APK signed with different key | Uninstall old app first |
|
||||
| `Account reauth failed [16]` | Missing or incorrect SHA-1 in Firebase | Re-add SHA-1 of keystore |
|
||||
| App opens in Firefox | Missing `WebViewClient` override | Fix `shouldOverrideUrlLoading` |
|
||||
| APK > 1 GB | Cached webpack artifacts included | Add `.next/` and `/public/cache` to `.gitignore` and build excludes |
|
||||
| Problem | Cause | Fix |
|
||||
|--------------------------------------|----------------------------------------|---------------------------------------------------------------------|
|
||||
| `INSTALL_FAILED_UPDATE_INCOMPATIBLE` | Old APK signed with different key | Uninstall old app first |
|
||||
| `Account reauth failed [16]` | Missing or incorrect SHA-1 in Firebase | Re-add SHA-1 of keystore |
|
||||
| App opens in Firefox | Missing `WebViewClient` override | Fix `shouldOverrideUrlLoading` |
|
||||
| APK > 1 GB | Cached webpack artifacts included | Add `.next/` and `/public/cache` to `.gitignore` and build excludes |
|
||||
|
||||
---
|
||||
|
||||
@@ -256,6 +293,8 @@ npx cap sync android
|
||||
|
||||
## 14. Deployment Workflow
|
||||
|
||||
To deploy manually:
|
||||
|
||||
```bash
|
||||
# Build web app for production and Sync assets to Android
|
||||
yarn build-sync-android
|
||||
@@ -263,25 +302,38 @@ yarn build-sync-android
|
||||
# Build signed release APK in Android Studio
|
||||
```
|
||||
|
||||
But prefer using the GitHub Action, see `Deploy to Play Store`.
|
||||
|
||||
---
|
||||
|
||||
## 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 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.
|
||||
Note: As of early 2026, we don't use the live update anymore because the free plan is too limited for our use case. To
|
||||
update the android app, we need to stick to the normal release process on the app stores.
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
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.
|
||||
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 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.
|
||||
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.
|
||||
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.
|
||||
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
|
||||
- 100 Monthly Active Users
|
||||
@@ -297,7 +349,6 @@ There is a limit of 100 monthly active user per month, though. So we may need to
|
||||
* [FCM HTTP API](https://firebase.google.com/docs/cloud-messaging/send-message)
|
||||
* [Next.js Deployment](https://nextjs.org/docs/deployment)
|
||||
|
||||
|
||||
# Useful Commands
|
||||
|
||||
- To build the project: `./gradlew assembleDebug`
|
||||
@@ -318,6 +369,7 @@ There is a limit of 100 monthly active user per month, though. So we may need to
|
||||
# One time setups
|
||||
|
||||
Was already done for Compass, so you only need to do the steps below if you create a project separated from Compass.
|
||||
|
||||
## Configure Firebase
|
||||
|
||||
### In Firebase Console
|
||||
@@ -345,7 +397,6 @@ keytool -list -v \
|
||||
|
||||
Add both SHA-1 and SHA-256 to Firebase.
|
||||
|
||||
|
||||
## 7. Google Sign-In (Web + Native)
|
||||
|
||||
In Firebase Console:
|
||||
@@ -357,10 +408,9 @@ In Firebase Console:
|
||||
In your code:
|
||||
|
||||
```ts
|
||||
import { googleNativeLogin } from 'web/lib/service/android-push'
|
||||
import {googleNativeLogin} from 'web/lib/service/android-push'
|
||||
```
|
||||
|
||||
|
||||
## 8. Push Notifications (FCM)
|
||||
|
||||
### Setup FCM
|
||||
@@ -381,17 +431,17 @@ import { googleNativeLogin } from 'web/lib/service/android-push'
|
||||
### Test notification
|
||||
|
||||
```ts
|
||||
const message = {
|
||||
notification: {
|
||||
title: "Test Notification",
|
||||
body: "Hello from Firebase Admin SDK"
|
||||
},
|
||||
token: "..."
|
||||
};
|
||||
initAdmin()
|
||||
await admin.messaging().send(message)
|
||||
.then(response => console.log("Successfully sent message:", response))
|
||||
.catch(error => console.error("Error sending message:", error));
|
||||
const message = {
|
||||
notification: {
|
||||
title: "Test Notification",
|
||||
body: "Hello from Firebase Admin SDK"
|
||||
},
|
||||
token: "..."
|
||||
};
|
||||
initAdmin()
|
||||
await admin.messaging().send(message)
|
||||
.then(response => console.log("Successfully sent message:", response))
|
||||
.catch(error => console.error("Error sending message:", error));
|
||||
```
|
||||
|
||||
---
|
||||
@@ -405,7 +455,77 @@ Example:
|
||||
com.compassconnections.app://auth
|
||||
```
|
||||
|
||||
|
||||
When Android (or iOS) sees a redirect to one of these URLs, it **launches your app** and passes it the URL data. It's useful to open links in the app instead of the browser. For example, if there's a link to Compass on Discord and we click on it on a mobile device that has the app, we want the link to open in the app instead of the browser.
|
||||
When Android (or iOS) sees a redirect to one of these URLs, it **launches your app** and passes it the URL data. It's
|
||||
useful to open links in the app instead of the browser. For example, if there's a link to Compass on Discord and we
|
||||
click on it on a mobile device that has the app, we want the link to open in the app instead of the browser.
|
||||
|
||||
You register this scheme in your `AndroidManifest.xml` so Android knows which app handles it.
|
||||
|
||||
## Automatic Workflow for App Release
|
||||
|
||||
Below is a **minimal, production-ready GitHub Actions setup** that:
|
||||
|
||||
* Builds on push to `main`
|
||||
* Checks if `versionCode` increased
|
||||
* Only builds if it did
|
||||
* Signs the AAB
|
||||
* Uploads to Google Play (internal track)
|
||||
|
||||
#### A. Create Play Console API access
|
||||
|
||||
1. Go to google cloud console. Create service account without selecting any specific permission or roles. Just copy
|
||||
paste the email address and generate a JSON key.
|
||||
2. Go to **Google Play Console**
|
||||
3. Invite user, enter the service account email address.
|
||||
5. Give it:
|
||||
* Release Manager role
|
||||
|
||||
You will store the JSON key in GitHub Secrets.
|
||||
|
||||
---
|
||||
|
||||
#### B. Prepare Keystore
|
||||
|
||||
If you already sign locally, you have a `.jks` or `.keystore` file.
|
||||
|
||||
Base64 encode it:
|
||||
|
||||
```bash
|
||||
base64 my-release-key.keystore
|
||||
```
|
||||
|
||||
Copy the output.
|
||||
|
||||
---
|
||||
|
||||
#### C. GitHub Secrets
|
||||
|
||||
In your GitHub repo:
|
||||
|
||||
Settings → Secrets and variables → Actions → New repository secret
|
||||
|
||||
Add:
|
||||
|
||||
```
|
||||
ANDROID_KEYSTORE_BASE64
|
||||
ANDROID_KEYSTORE_PASSWORD
|
||||
ANDROID_KEY_PASSWORD
|
||||
PLAY_SERVICE_ACCOUNT_JSON
|
||||
```
|
||||
|
||||
For the JSON:
|
||||
|
||||
* Paste full raw JSON (not base64)
|
||||
|
||||
#### GitHub Actions YAML
|
||||
|
||||
We compare:
|
||||
|
||||
* `versionCode` in current commit
|
||||
* `versionCode` in previous commit
|
||||
|
||||
If not increased → skip build.
|
||||
|
||||
We extract from `app/build.gradle` using grep.
|
||||
|
||||
See `.github/workflows/android-release.yml` for all details.
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
|
||||
android {
|
||||
namespace "com.compassconnections.app"
|
||||
compileSdk 36
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_21
|
||||
targetCompatibility JavaVersion.VERSION_21
|
||||
}
|
||||
defaultConfig {
|
||||
applicationId "com.compassconnections.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 39
|
||||
versionName "1.8.0"
|
||||
versionCode 79
|
||||
versionName "1.16.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
@@ -17,10 +20,17 @@ android {
|
||||
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||
}
|
||||
}
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||
buildConfigField "boolean", "ENABLE_WEBVIEW_DEBUG", "false"
|
||||
}
|
||||
debug {
|
||||
buildConfigField "boolean", "ENABLE_WEBVIEW_DEBUG", "true"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,7 +60,7 @@ dependencies {
|
||||
// Add the dependencies for any other desired Firebase products
|
||||
// https://firebase.google.com/docs/android/setup#available-libraries
|
||||
|
||||
implementation 'com.google.android.gms:play-services-auth:21.4.0'
|
||||
implementation 'com.google.android.gms:play-services-auth:21.5.1'
|
||||
implementation 'com.google.firebase:firebase-auth:24.0.1'
|
||||
|
||||
implementation 'com.google.android.play:app-update:2.1.0'
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
android {
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_21
|
||||
targetCompatibility JavaVersion.VERSION_21
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
android:fitsSystemWindows="true"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:theme="@style/AppTheme">
|
||||
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
|
||||
android:name=".MainActivity"
|
||||
@@ -18,19 +17,28 @@
|
||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||
android:launchMode="singleTask"
|
||||
android:exported="true">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- <intent-filter>-->
|
||||
<!-- <action android:name="openapp" />-->
|
||||
<!-- <category android:name="android.intent.category.DEFAULT" />-->
|
||||
<!-- <category android:name="android.intent.category.BROWSABLE" />-->
|
||||
<intent-filter android:autoVerify="true">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="https" />
|
||||
<data android:host="compassmeet.com" />
|
||||
<data android:scheme="https" />
|
||||
<data android:host="www.compassmeet.com" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- <data android:scheme="com.compassconnections.app" android:host="details"/>-->
|
||||
<!-- </intent-filter>-->
|
||||
<!-- <intent-filter>-->
|
||||
<!-- <action android:name="openapp" />-->
|
||||
<!-- <category android:name="android.intent.category.DEFAULT" />-->
|
||||
<!-- <category android:name="android.intent.category.BROWSABLE" />-->
|
||||
|
||||
<!-- <data android:scheme="com.compassconnections.app" android:host="details"/>-->
|
||||
<!-- </intent-filter>-->
|
||||
|
||||
<!-- <intent-filter android:autoVerify="true">-->
|
||||
<!-- <action android:name="android.intent.action.VIEW" />-->
|
||||
@@ -40,16 +48,16 @@
|
||||
<!-- </intent-filter>-->
|
||||
</activity>
|
||||
|
||||
<!-- <service-->
|
||||
<!-- android:name=".MyMessagingService"-->
|
||||
<!-- android:exported="false">-->
|
||||
<!-- <intent-filter>-->
|
||||
<!-- <action android:name="com.google.firebase.MESSAGING_EVENT" />-->
|
||||
<!-- </intent-filter>-->
|
||||
<!--<!– <meta-data–>-->
|
||||
<!--<!– android:name="com.google.firebase.messaging.default_notification_channel_id"–>-->
|
||||
<!--<!– android:value="@string/default_notification_channel_id" />–>-->
|
||||
<!-- </service>-->
|
||||
<!-- <service-->
|
||||
<!-- android:name=".MyMessagingService"-->
|
||||
<!-- android:exported="false">-->
|
||||
<!-- <intent-filter>-->
|
||||
<!-- <action android:name="com.google.firebase.MESSAGING_EVENT" />-->
|
||||
<!-- </intent-filter>-->
|
||||
<!--<!– <meta-data–>-->
|
||||
<!--<!– android:name="com.google.firebase.messaging.default_notification_channel_id"–>-->
|
||||
<!--<!– android:value="@string/default_notification_channel_id" />–>-->
|
||||
<!-- </service>-->
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
@@ -66,7 +74,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="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
|
||||
<uses-permission android:name="com.google.android.gms.permission.AD_ID" tools:node="remove" />
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import ee.forgr.capacitor.social.login.GoogleProvider;
|
||||
@@ -50,6 +51,8 @@ import ee.forgr.capacitor.social.login.SocialLoginPlugin;
|
||||
|
||||
public class MainActivity extends BridgeActivity implements ModifiedMainActivityForSocialLoginPlugin {
|
||||
|
||||
private String pendingDeepLink = null;
|
||||
|
||||
// Declare this at class level
|
||||
private final ActivityResultLauncher<String> requestPermissionLauncher =
|
||||
registerForActivityResult(new ActivityResultContracts.RequestPermission(), isGranted -> {
|
||||
@@ -79,6 +82,13 @@ public class MainActivity extends BridgeActivity implements ModifiedMainActivity
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
public String getPendingDeepLink() {
|
||||
String link = pendingDeepLink;
|
||||
pendingDeepLink = null; // consume it
|
||||
return link;
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
public void downloadFile(String filename, String content) {
|
||||
try {
|
||||
@@ -177,20 +187,24 @@ public class MainActivity extends BridgeActivity implements ModifiedMainActivity
|
||||
protected void onNewIntent(Intent intent) {
|
||||
super.onNewIntent(intent);
|
||||
|
||||
// String data = intent.getDataString();
|
||||
String endpoint = intent.getStringExtra("endpoint");
|
||||
Log.i("CompassApp", "onNewIntent called with endpoint: " + endpoint);
|
||||
if (endpoint != null) {
|
||||
Log.i("CompassApp", "redirecting to endpoint: " + endpoint);
|
||||
try {
|
||||
String payload = new JSONObject().put("endpoint", endpoint).toString();
|
||||
Log.i("CompassApp", "Payload: " + payload);
|
||||
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript("bridgeRedirect(" + payload + ");", null));
|
||||
Log.i("CompassApp", "Handling notif click: " + payload);
|
||||
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript("handleAppLink(" + payload + ");", null));
|
||||
} catch (JSONException e) {
|
||||
Log.i("CompassApp", "Failed to encode JSON payload", e);
|
||||
}
|
||||
} else {
|
||||
Log.i("CompassApp", "No relevant data");
|
||||
Uri data = intent.getData();
|
||||
if (data != null) {
|
||||
handleDeepLink(data.toString());
|
||||
} else {
|
||||
Log.i("CompassApp", "No relevant data");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,8 +216,9 @@ public class MainActivity extends BridgeActivity implements ModifiedMainActivity
|
||||
WebView webView = this.bridge.getWebView();
|
||||
webView.setWebViewClient(new BridgeWebViewClient(this.bridge));
|
||||
|
||||
WebView.setWebContentsDebuggingEnabled(true);
|
||||
|
||||
if (BuildConfig.ENABLE_WEBVIEW_DEBUG) {
|
||||
WebView.setWebContentsDebuggingEnabled(true);
|
||||
}
|
||||
// Set a recognizable User-Agent (always reliable)
|
||||
WebSettings settings = webView.getSettings();
|
||||
settings.setUserAgentString(settings.getUserAgentString() + " CompassAppWebView");
|
||||
@@ -221,6 +236,20 @@ public class MainActivity extends BridgeActivity implements ModifiedMainActivity
|
||||
|
||||
appUpdateManager = AppUpdateManagerFactory.create(this);
|
||||
checkForUpdates();
|
||||
|
||||
Uri data = getIntent().getData();
|
||||
if (data != null) pendingDeepLink = data.toString();
|
||||
}
|
||||
|
||||
private void handleDeepLink(String url) {
|
||||
try {
|
||||
String path = new URL(url).getPath();
|
||||
String payload = new JSONObject().put("url", url).put("endpoint", path).toString();
|
||||
Log.i("CompassApp", "Handling deep link: " + url);
|
||||
bridge.getWebView().post(() -> bridge.getWebView().evaluateJavascript("handleAppLink(" + payload + ");", null));
|
||||
} catch (Exception e) {
|
||||
Log.e("CompassApp", "Failed to handle deep link for " + url, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
BIN
assets/icon.png
Normal file
BIN
assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 226 KiB |
@@ -1,43 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: ['lodash', 'unused-imports', 'simple-import-sort'],
|
||||
extends: ['eslint:recommended'],
|
||||
ignorePatterns: ['dist', 'lib', 'coverage'],
|
||||
env: {
|
||||
node: true,
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
plugins: ['@typescript-eslint'],
|
||||
extends: ['plugin:@typescript-eslint/recommended', 'prettier'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
project: ['./tsconfig.json', './tsconfig.test.json'],
|
||||
},
|
||||
rules: {
|
||||
'@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': [
|
||||
'warn',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'unused-imports/no-unused-imports': 'warn',
|
||||
'no-constant-condition': 'off',
|
||||
},
|
||||
},
|
||||
],
|
||||
rules: {
|
||||
'linebreak-style': ['error', process.platform === 'win32' ? 'windows' : 'unix'],
|
||||
'lodash/import-scope': [2, 'member'],
|
||||
'simple-import-sort/imports': 'error',
|
||||
'simple-import-sort/exports': 'error',
|
||||
},
|
||||
}
|
||||
@@ -14,6 +14,9 @@ RUN yarn install --frozen-lockfile --production && \
|
||||
yarn cache clean --force && \
|
||||
rm -rf /usr/local/share/.cache/yarn
|
||||
|
||||
# Show installed packages
|
||||
RUN npm list || true
|
||||
|
||||
# Copy over typescript payload
|
||||
COPY dist ./
|
||||
|
||||
|
||||
@@ -1,11 +1,67 @@
|
||||
# Backend API
|
||||
|
||||
This is the code for the API running at https://api.compassmeet.com.
|
||||
It runs in a docker inside a Google Cloud virtual machine.
|
||||
Express.js REST API for Compass, running at https://api.compassmeet.com.
|
||||
|
||||
### Requirements
|
||||
## Overview
|
||||
|
||||
You must have the `gcloud` CLI.
|
||||
The API handles:
|
||||
|
||||
- User authentication and management
|
||||
- Profile CRUD operations
|
||||
- Search and filtering
|
||||
- Messaging
|
||||
- Notifications
|
||||
- Compatibility scoring
|
||||
- Events management
|
||||
- WebSocket connections for real-time features
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Runtime**: Node.js 20+
|
||||
- **Framework**: Express.js 5.0
|
||||
- **Language**: TypeScript
|
||||
- **Database**: PostgreSQL (via Supabase)
|
||||
- **ORM**: pg-promise
|
||||
- **Validation**: Zod
|
||||
- **WebSocket**: ws library
|
||||
- **API Docs**: Swagger/OpenAPI
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
backend/api/
|
||||
├── src/
|
||||
│ ├── app.ts # Express app setup
|
||||
│ ├── routes.ts # Route definitions
|
||||
│ ├── test.ts # Test utilities
|
||||
│ ├── get-*.ts # GET endpoints
|
||||
│ ├── create-*.ts # POST endpoints
|
||||
│ ├── update-*.ts # PUT/PATCH endpoints
|
||||
│ ├── delete-*.ts # DELETE endpoints
|
||||
│ └── helpers/ # Shared utilities
|
||||
├── tests/
|
||||
│ └── unit/ # Unit tests
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 20.x or later
|
||||
- Yarn
|
||||
- Access to Supabase project (for database)
|
||||
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
# From root directory
|
||||
yarn install
|
||||
```
|
||||
|
||||
You must also have the `gcloud` CLI.
|
||||
|
||||
On macOS:
|
||||
|
||||
@@ -35,6 +91,355 @@ You also need `opentofu` and `docker`. Try running this (from root) on Linux or
|
||||
|
||||
If it doesn't work, you can install them manually (google how to install `opentofu` and `docker` for your OS).
|
||||
|
||||
### Running Locally
|
||||
|
||||
```bash
|
||||
# Run all services (web + API)
|
||||
yarn dev
|
||||
|
||||
# Run API only (from backend/api)
|
||||
cd backend/api
|
||||
yarn serve
|
||||
```
|
||||
|
||||
The API runs on http://localhost:8088 when running locally with the full stack.
|
||||
|
||||
### Testing
|
||||
|
||||
```bash
|
||||
# Run unit tests
|
||||
yarn test
|
||||
|
||||
# Run with coverage
|
||||
yarn test --coverage
|
||||
```
|
||||
|
||||
### Linting
|
||||
|
||||
```bash
|
||||
# Check lint
|
||||
yarn lint
|
||||
|
||||
# Fix issues
|
||||
yarn lint-fix
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Authentication
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | -------------- | --------------- |
|
||||
| POST | `/create-user` | Create new user |
|
||||
|
||||
### Users
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | ------------ | ------------------- |
|
||||
| GET | `/get-me` | Get current user |
|
||||
| PUT | `/update-me` | Update current user |
|
||||
| DELETE | `/delete-me` | Delete account |
|
||||
|
||||
### Profiles
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | ----------------- | ------------------ |
|
||||
| GET | `/get-profiles` | List profiles |
|
||||
| GET | `/get-profile` | Get single profile |
|
||||
| POST | `/create-profile` | Create profile |
|
||||
| PUT | `/update-profile` | Update profile |
|
||||
| DELETE | `/delete-profile` | Delete profile |
|
||||
|
||||
### Messaging
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | ------------------------------ | -------------- |
|
||||
| GET | `/get-private-messages` | Get messages |
|
||||
| POST | `/create-private-user-message` | Send message |
|
||||
| PUT | `/edit-message` | Edit message |
|
||||
| DELETE | `/delete-message` | Delete message |
|
||||
|
||||
### Notifications
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | ----------------------- | ------------------ |
|
||||
| GET | `/get-notifications` | List notifications |
|
||||
| PUT | `/update-notif-setting` | Update settings |
|
||||
|
||||
### Search
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | ------------------ | ------------------ |
|
||||
| GET | `/search-users` | Search users |
|
||||
| GET | `/search-location` | Search by location |
|
||||
|
||||
### Compatibility
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | ------------------------------ | ----------------------- |
|
||||
| GET | `/get-compatibility-questions` | List questions |
|
||||
| POST | `/set-compatibility-answers` | Submit answers |
|
||||
| GET | `/compatible-profiles` | Get compatible profiles |
|
||||
|
||||
## Writing Endpoints
|
||||
|
||||
### 1. Define Schema
|
||||
|
||||
Add endpoint definition in `common/src/api/schema.ts`:
|
||||
|
||||
```typescript
|
||||
const endpoints = {
|
||||
myEndpoint: {
|
||||
method: 'POST',
|
||||
authed: true,
|
||||
returns: z.object({
|
||||
success: z.boolean(),
|
||||
data: z.any(),
|
||||
}),
|
||||
props: z
|
||||
.object({
|
||||
userId: z.string(),
|
||||
option: z.string().optional(),
|
||||
})
|
||||
.strict(),
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Implement Handler
|
||||
|
||||
Create handler file in `backend/api/src/`:
|
||||
|
||||
```typescript
|
||||
import {z} from 'zod'
|
||||
import {APIHandler} from './helpers/endpoint'
|
||||
|
||||
export const myEndpoint: APIHandler<'myEndpoint'> = async (props, auth) => {
|
||||
const {userId, option} = props
|
||||
|
||||
// Implementation
|
||||
return {
|
||||
success: true,
|
||||
data: {userId},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Register Route
|
||||
|
||||
Add to `routes.ts`:
|
||||
|
||||
```typescript
|
||||
import {myEndpoint} from './my-endpoint'
|
||||
|
||||
const handlers = {
|
||||
myEndpoint,
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
### Authenticated Endpoints
|
||||
|
||||
Use the `authed: true` schema property. The auth object is passed to the handler:
|
||||
|
||||
```typescript
|
||||
export const getProfile: APIHandler<'get-profile'> = async (props, auth) => {
|
||||
// auth.uid - user ID
|
||||
// auth.creds - credentials type
|
||||
}
|
||||
```
|
||||
|
||||
### Auth Types
|
||||
|
||||
- `firebase` - Firebase Auth token
|
||||
- `session` - Session-based auth
|
||||
|
||||
## Database Access
|
||||
|
||||
### Using pg-promise
|
||||
|
||||
```typescript
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const result = await pg.oneOrNone<User>('SELECT * FROM users WHERE id = $1', [userId])
|
||||
```
|
||||
|
||||
### Using Supabase Client
|
||||
|
||||
But this works only in the front-end.
|
||||
|
||||
```typescript
|
||||
import {db} from 'web/lib/supabase/db'
|
||||
|
||||
const {data, error} = await db.from('profiles').select('*').eq('user_id', userId)
|
||||
```
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
The API includes built-in rate limiting:
|
||||
|
||||
```typescript
|
||||
export const myEndpoint: APIHandler<'myEndpoint'> = withRateLimit(
|
||||
async (props, auth) => {
|
||||
// Handler implementation
|
||||
},
|
||||
{
|
||||
name: 'my-endpoint',
|
||||
limit: 100,
|
||||
windowMs: 60 * 1000, // 1 minute
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
Use `APIError` for consistent error responses:
|
||||
|
||||
```typescript
|
||||
import {APIError} from './helpers/endpoint'
|
||||
|
||||
throw APIError(404, 'User not found')
|
||||
throw APIError(400, 'Invalid input', {field: 'email'})
|
||||
```
|
||||
|
||||
Error codes:
|
||||
|
||||
- `400` - Bad Request
|
||||
- `401` - Unauthorized
|
||||
- `403` - Forbidden
|
||||
- `404` - Not Found
|
||||
- `429` - Too Many Requests
|
||||
- `500` - Internal Server Error
|
||||
|
||||
## WebSocket
|
||||
|
||||
WebSocket connections are handled for real-time features:
|
||||
|
||||
```typescript
|
||||
// Subscribe to updates
|
||||
ws.subscribe('user/123', (data) => {
|
||||
console.log('User updated:', data)
|
||||
})
|
||||
|
||||
// Unsubscribe
|
||||
ws.unsubscribe('user/123', callback)
|
||||
```
|
||||
|
||||
Available topics:
|
||||
|
||||
- `user/{userId}` - User updates
|
||||
- `private-user/{userId}` - Private user updates
|
||||
- `message/{channelId}` - New messages
|
||||
|
||||
## Logging
|
||||
|
||||
Use the shared logger:
|
||||
|
||||
```typescript
|
||||
import {log} from 'shared/monitoring/log'
|
||||
|
||||
log.info('Processing request', {userId: auth.uid})
|
||||
log.error('Failed to process', error)
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
### Production Deployment
|
||||
|
||||
Deployments are automated via GitHub Actions. Push to main triggers deployment:
|
||||
|
||||
```bash
|
||||
# Increment version
|
||||
# Update package.json version
|
||||
git add package.json
|
||||
git commit -m "chore: bump version"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
### Manual Deployment
|
||||
|
||||
```bash
|
||||
cd backend/api
|
||||
./deploy-api.sh prod
|
||||
```
|
||||
|
||||
### Server Access
|
||||
|
||||
Run in this directory to connect to the API server running as virtual machine in Google Cloud. You can access logs,
|
||||
files, debug, etc.
|
||||
|
||||
```bash
|
||||
# SSH into production server
|
||||
cd backend/api
|
||||
./ssh-api.sh prod
|
||||
```
|
||||
|
||||
Useful commands on server:
|
||||
|
||||
```bash
|
||||
sudo journalctl -u konlet-startup --no-pager -ef # View logs
|
||||
sudo docker logs -f $(sudo docker ps -alq) # Container logs
|
||||
docker exec -it $(sudo docker ps -alq) sh # Shell access
|
||||
docker run -it --rm $(docker images -q | head -n 1) sh
|
||||
docker rmi -f $(docker images -aq)
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Required secrets (set in Google Cloud Secrets Manager):
|
||||
|
||||
| Variable | Description |
|
||||
| ---------------------- | ---------------------------- |
|
||||
| `DATABASE_URL` | PostgreSQL connection string |
|
||||
| `FIREBASE_PROJECT_ID` | Firebase project ID |
|
||||
| `FIREBASE_PRIVATE_KEY` | Firebase private key |
|
||||
| `SUPABASE_SERVICE_KEY` | Supabase service role key |
|
||||
| `JWT_SECRET` | JWT signing secret |
|
||||
|
||||
## Testing
|
||||
|
||||
### Writing Unit Tests
|
||||
|
||||
```typescript
|
||||
// tests/unit/my-endpoint.unit.test.ts
|
||||
import {myEndpoint} from '../my-endpoint'
|
||||
|
||||
describe('myEndpoint', () => {
|
||||
it('should return success', async () => {
|
||||
const result = await myEndpoint({userId: '123'}, mockAuth)
|
||||
expect(result.success).toBe(true)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### Mocking Database
|
||||
|
||||
```typescript
|
||||
const mockPg = {
|
||||
oneOrNone: jest.fn().mockResolvedValue({id: '123'}),
|
||||
}
|
||||
```
|
||||
|
||||
## API Documentation
|
||||
|
||||
Full API docs available at:
|
||||
|
||||
- Production: https://api.compassmeet.com
|
||||
- Local: http://localhost:8088 (when running)
|
||||
|
||||
Docs are generated from route definitions in `app.ts`.
|
||||
|
||||
## See Also
|
||||
|
||||
- [Main README](../../README.md)
|
||||
- [Contributing Guide](../../CONTRIBUTING.md)
|
||||
- [Shared Backend Utils](../shared/README.md)
|
||||
- [Database Migrations](../../supabase)
|
||||
|
||||
### Setup
|
||||
|
||||
This section is only for the people who are creating a server from scratch, for instance for a forked project.
|
||||
@@ -155,48 +560,3 @@ in [Google Cloud Secrets manager](https://console.cloud.google.com/security/secr
|
||||
can access them.
|
||||
|
||||
For Compass, the name of the secrets are in [secrets.ts](../../common/src/secrets.ts).
|
||||
|
||||
### Run Locally
|
||||
|
||||
In root directory, run the local api with hot reload, along with all the other backend and web code.
|
||||
|
||||
```bash
|
||||
./run_local.sh prod
|
||||
```
|
||||
|
||||
### Deploy
|
||||
|
||||
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
|
||||
```
|
||||
|
||||
### Connect to the server
|
||||
|
||||
Run in this directory to connect to the API server running as virtual machine in Google Cloud. You can access logs,
|
||||
files, debug, etc.
|
||||
|
||||
```bash
|
||||
./ssh-api.sh prod
|
||||
```
|
||||
|
||||
Useful commands once inside the server:
|
||||
|
||||
```bash
|
||||
sudo journalctl -u konlet-startup --no-pager -efb
|
||||
sudo docker logs -f $(sudo docker ps -alq)
|
||||
docker exec -it $(sudo docker ps -alq) sh
|
||||
docker run -it --rm $(docker images -q | head -n 1) sh
|
||||
docker rmi -f $(docker images -aq)
|
||||
```
|
||||
|
||||
### Documentation
|
||||
|
||||
The API 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
|
||||
|
||||
@@ -49,7 +49,7 @@ echo "🚀 Deploying ${SERVICE_NAME} to ${ENV} ($(date "+%Y-%m-%d %I:%M:%S %p"))
|
||||
yarn build
|
||||
|
||||
gcloud auth print-access-token | docker login -u oauth2accesstoken --password-stdin us-west1-docker.pkg.dev
|
||||
docker build . --tag ${IMAGE_URL} --platform linux/amd64
|
||||
docker build . --tag ${IMAGE_URL} --platform linux/amd64 --progress=plain
|
||||
echo "docker push ${IMAGE_URL}"
|
||||
docker push ${IMAGE_URL}
|
||||
|
||||
|
||||
31
backend/api/dist_copy.sh
Executable file
31
backend/api/dist_copy.sh
Executable file
@@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
rsync -a --delete ../../common/lib/ dist/common/lib
|
||||
rsync -a --delete ../../common/messages/ dist/common/messages/
|
||||
|
||||
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 package.json dist/backend/api
|
||||
cp metadata.json dist
|
||||
cp metadata.json dist/backend/api
|
||||
|
||||
cp ../../yarn.lock dist
|
||||
|
||||
# Installing from backend/api/package.json is not enough
|
||||
# Need to install the deps from all the workspaces used in the back end
|
||||
node -e "
|
||||
const fs = require('fs');
|
||||
const deps = ['../api', '../shared', '../email', '../../common']
|
||||
.map(p => require('./' + p + '/package.json').dependencies || {})
|
||||
.reduce((acc, d) => ({ ...acc, ...d }), {});
|
||||
const pkg = require('./package.json');
|
||||
pkg.dependencies = { ...deps, ...pkg.dependencies };
|
||||
fs.writeFileSync('./dist/package.json', JSON.stringify(pkg, null, 2));
|
||||
"
|
||||
76
backend/api/eslint.config.mjs
Normal file
76
backend/api/eslint.config.mjs
Normal file
@@ -0,0 +1,76 @@
|
||||
import js from '@eslint/js'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import lodash from 'eslint-plugin-lodash'
|
||||
import unusedImports from 'eslint-plugin-unused-imports'
|
||||
import simpleImportSort from 'eslint-plugin-simple-import-sort'
|
||||
import eslintConfigPrettier from 'eslint-config-prettier'
|
||||
|
||||
export default tseslint.config(
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.recommended,
|
||||
{
|
||||
plugins: {
|
||||
lodash,
|
||||
'unused-imports': unusedImports,
|
||||
'simple-import-sort': simpleImportSort,
|
||||
},
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.json', './tsconfig.test.json', 'tsconfig.eslint.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
globals: {
|
||||
process: 'readonly',
|
||||
console: 'readonly',
|
||||
module: 'readonly',
|
||||
require: 'readonly',
|
||||
__dirname: 'readonly',
|
||||
__filename: 'readonly',
|
||||
Buffer: 'readonly',
|
||||
global: 'readonly',
|
||||
setTimeout: 'readonly',
|
||||
clearTimeout: 'readonly',
|
||||
setInterval: 'readonly',
|
||||
clearInterval: 'readonly',
|
||||
setImmediate: 'readonly',
|
||||
clearImmediate: 'readonly',
|
||||
URL: 'readonly',
|
||||
URLSearchParams: 'readonly',
|
||||
fetch: 'readonly',
|
||||
WebSocket: 'readonly',
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/no-empty-object-type': 'error',
|
||||
'@typescript-eslint/no-unsafe-function-type': 'error',
|
||||
'@typescript-eslint/no-wrapper-object-types': 'error',
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-extra-semi': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'warn',
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
},
|
||||
],
|
||||
'unused-imports/no-unused-imports': 'warn',
|
||||
'no-constant-condition': 'off',
|
||||
'linebreak-style': ['error', process.platform === 'win32' ? 'windows' : 'unix'],
|
||||
'lodash/import-scope': [2, 'member'],
|
||||
'simple-import-sort/imports': 'error',
|
||||
'simple-import-sort/exports': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
'dist',
|
||||
'lib',
|
||||
'coverage',
|
||||
'eslint.config.mjs',
|
||||
'jest.config.ts',
|
||||
'ecosystem.config.js',
|
||||
],
|
||||
},
|
||||
eslintConfigPrettier,
|
||||
)
|
||||
@@ -25,4 +25,5 @@ module.exports = {
|
||||
},
|
||||
|
||||
collectCoverageFrom: ['src/**/*.{ts,tsx}', '!src/**/*.d.ts'],
|
||||
silent: true,
|
||||
}
|
||||
@@ -11,8 +11,8 @@ variable "env" {
|
||||
}
|
||||
|
||||
locals {
|
||||
project = "compass-130ba"
|
||||
region = "us-west1"
|
||||
project = "compass-130ba"
|
||||
region = "us-west1"
|
||||
zone = "us-west1-b"
|
||||
service_name = "api"
|
||||
machine_type = "e2-small"
|
||||
@@ -55,7 +55,7 @@ resource "google_storage_bucket" "public_storage" {
|
||||
|
||||
# static IPs
|
||||
resource "google_compute_global_address" "api_lb_ip" {
|
||||
name = "api-lb-ip-2"
|
||||
name = "api-lb-ip-2"
|
||||
address_type = "EXTERNAL"
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ resource "google_compute_instance_template" "api_template" {
|
||||
}
|
||||
|
||||
network_interface {
|
||||
network = "default"
|
||||
network = "default"
|
||||
subnetwork = "default"
|
||||
access_config {
|
||||
network_tier = "PREMIUM"
|
||||
@@ -105,6 +105,7 @@ spec:
|
||||
ports:
|
||||
- containerPort: 80
|
||||
EOF
|
||||
google-logging-enabled = "true"
|
||||
}
|
||||
|
||||
lifecycle {
|
||||
@@ -116,12 +117,12 @@ EOF
|
||||
resource "google_compute_region_instance_group_manager" "api_group" {
|
||||
name = "${local.service_name}-group"
|
||||
base_instance_name = "${local.service_name}-group"
|
||||
region = local.region
|
||||
region = local.region
|
||||
target_size = 1
|
||||
|
||||
version {
|
||||
instance_template = google_compute_instance_template.api_template.id
|
||||
name = "primary"
|
||||
name = "primary"
|
||||
}
|
||||
|
||||
update_policy {
|
||||
@@ -185,29 +186,29 @@ resource "google_compute_url_map" "api_url_map" {
|
||||
path_matcher {
|
||||
name = "allpaths"
|
||||
default_service = google_compute_backend_service.api_backend.self_link
|
||||
#
|
||||
# # Priority 0: passthrough /v0/* requests
|
||||
# route_rules {
|
||||
# priority = 1
|
||||
# match_rules {
|
||||
# prefix_match = "/v0"
|
||||
# }
|
||||
# service = google_compute_backend_service.api_backend.self_link
|
||||
# }
|
||||
#
|
||||
# # Priority 1: rewrite everything else to /v0
|
||||
# route_rules {
|
||||
# priority = 2
|
||||
# match_rules {
|
||||
# prefix_match = "/"
|
||||
# }
|
||||
# route_action {
|
||||
# url_rewrite { # This may break websockets (the Upgrade and Connection headers must pass through untouched).
|
||||
# path_prefix_rewrite = "/v0/"
|
||||
# }
|
||||
# }
|
||||
# service = google_compute_backend_service.api_backend.self_link
|
||||
# }
|
||||
#
|
||||
# # Priority 0: passthrough /v0/* requests
|
||||
# route_rules {
|
||||
# priority = 1
|
||||
# match_rules {
|
||||
# prefix_match = "/v0"
|
||||
# }
|
||||
# service = google_compute_backend_service.api_backend.self_link
|
||||
# }
|
||||
#
|
||||
# # Priority 1: rewrite everything else to /v0
|
||||
# route_rules {
|
||||
# priority = 2
|
||||
# match_rules {
|
||||
# prefix_match = "/"
|
||||
# }
|
||||
# route_action {
|
||||
# url_rewrite { # This may break websockets (the Upgrade and Connection headers must pass through untouched).
|
||||
# path_prefix_rewrite = "/v0/"
|
||||
# }
|
||||
# }
|
||||
# service = google_compute_backend_service.api_backend.self_link
|
||||
# }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,10 +268,10 @@ resource "google_compute_firewall" "allow_health_check" {
|
||||
}
|
||||
|
||||
resource "google_compute_firewall" "default_allow_https" {
|
||||
name = "default-allow-http"
|
||||
network = "default"
|
||||
priority = 1000
|
||||
direction = "INGRESS"
|
||||
name = "default-allow-http"
|
||||
network = "default"
|
||||
priority = 1000
|
||||
direction = "INGRESS"
|
||||
|
||||
allow {
|
||||
protocol = "tcp"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"git": {
|
||||
"revision": "704bcb4",
|
||||
"commitDate": "2025-12-15 13:38:09 +0200",
|
||||
"revision": "c085e8f",
|
||||
"commitDate": "2026-02-22 21:51:08 +0100",
|
||||
"author": "MartinBraquet",
|
||||
"message": "Increase API docs font size on mobile"
|
||||
"message": "Add guidelines for adding translations to existing files"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,80 +1,66 @@
|
||||
{
|
||||
"name": "@compass/api",
|
||||
"description": "Backend API endpoints",
|
||||
"version": "1.12.0",
|
||||
"version": "1.30.3",
|
||||
"private": true,
|
||||
"description": "Backend API endpoints",
|
||||
"main": "src/serve.ts",
|
||||
"scripts": {
|
||||
"watch:serve": "tsx watch src/serve.ts",
|
||||
"watch:compile": "npx concurrently \"tsc -b --watch --preserveWatchOutput\" \"(cd ../../common && tsc-alias --watch)\" \"(cd ../shared && tsc-alias --watch)\" \"(cd ../email && tsc-alias --watch)\" \"tsc-alias --watch\"",
|
||||
"dev": "yarn watch:serve",
|
||||
"prod": "npx concurrently -n COMPILE,SERVER -c cyan,green \"yarn watch:compile\" \"yarn watch:serve\"",
|
||||
"build": "yarn compile && yarn dist:clean && yarn dist:copy",
|
||||
"build:fast": "yarn compile && yarn dist:copy",
|
||||
"clean": "rm -rf lib && (cd ../../common && rm -rf lib) && (cd ../shared && rm -rf lib) && (cd ../email && rm -rf lib)",
|
||||
"compile": "tsc -b && tsc-alias && (cd ../../common && tsc-alias) && (cd ../shared && tsc-alias) && (cd ../email && tsc-alias) && cp -r src/public/ lib/",
|
||||
"debug": "nodemon -r tsconfig-paths/register --watch src -e ts --watch ../../common/src --watch ../shared/src --exec \"yarn build && node --inspect-brk src/serve.ts\"",
|
||||
"dev": "yarn watch:serve",
|
||||
"dist": "yarn dist:clean && yarn dist:copy",
|
||||
"dist:clean": "rm -rf dist && mkdir -p dist/common/lib dist/backend/shared/lib dist/backend/api/lib dist/backend/email/lib",
|
||||
"dist:copy": "rsync -a --delete ../../common/lib/ dist/common/lib && rsync -a --delete ../shared/lib/ dist/backend/shared/lib && rsync -a --delete ../email/lib/ dist/backend/email/lib && rsync -a --delete ./lib/* dist/backend/api/lib && cp ../../yarn.lock dist && cp package.json dist && cp package.json dist/backend/api && cp metadata.json dist && cp metadata.json dist/backend/api",
|
||||
"watch": "tsc -w",
|
||||
"dist:copy": "./dist_copy.sh",
|
||||
"lint": "npx eslint . --max-warnings 0",
|
||||
"lint-fix": "npx eslint . --fix",
|
||||
"typecheck": "yarn build && npx tsc --noEmit",
|
||||
"prod": "npx concurrently -n COMPILE,SERVER -c cyan,green \"yarn watch:compile\" \"yarn watch:serve\"",
|
||||
"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",
|
||||
"test:coverage": "jest --config jest.config.js --coverage"
|
||||
"test": "jest --config jest.config.ts",
|
||||
"test:coverage": "jest --config jest.config.ts --coverage",
|
||||
"typecheck": "yarn build && npx tsc --noEmit",
|
||||
"watch:compile": "npx concurrently \"tsc -b --watch --preserveWatchOutput\" \"(cd ../../common && tsc-alias --watch)\" \"(cd ../shared && tsc-alias --watch)\" \"(cd ../email && tsc-alias --watch)\" \"tsc-alias --watch\"",
|
||||
"watch:serve": "tsx watch src/serve.ts"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"main": "src/serve.ts",
|
||||
"dependencies": {
|
||||
"@google-cloud/monitoring": "4.0.0",
|
||||
"@google-cloud/secret-manager": "4.2.1",
|
||||
"@react-email/components": "0.0.33",
|
||||
"@supabase/supabase-js": "2.38.5",
|
||||
"@tiptap/core": "2.3.2",
|
||||
"@tiptap/extension-blockquote": "2.3.2",
|
||||
"@tiptap/extension-bold": "2.3.2",
|
||||
"@tiptap/extension-bubble-menu": "2.3.2",
|
||||
"@tiptap/extension-floating-menu": "2.3.2",
|
||||
"@tiptap/extension-image": "2.3.2",
|
||||
"@tiptap/extension-link": "2.3.2",
|
||||
"@tiptap/extension-mention": "2.3.2",
|
||||
"@tiptap/html": "2.3.2",
|
||||
"@tiptap/pm": "2.3.2",
|
||||
"@tiptap/starter-kit": "2.3.2",
|
||||
"@tiptap/suggestion": "2.3.2",
|
||||
"colors": "1.4.0",
|
||||
"@mozilla/readability": "0.6.0",
|
||||
"@sentry/node": "10.41.0",
|
||||
"@tiptap/core": "2.10.4",
|
||||
"cors": "2.8.5",
|
||||
"dayjs": "1.11.4",
|
||||
"dayjs": "1.11.19",
|
||||
"express": "5.0.0",
|
||||
"firebase-admin": "13.5.0",
|
||||
"gcp-metadata": "6.1.0",
|
||||
"jsdom": "29.0.1",
|
||||
"jsonwebtoken": "9.0.0",
|
||||
"lodash": "4.17.23",
|
||||
"marked": "17.0.5",
|
||||
"openapi-types": "12.1.3",
|
||||
"pg-promise": "11.5.5",
|
||||
"pg-promise": "12.6.1",
|
||||
"posthog-node": "4.11.0",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"resend": "4.1.2",
|
||||
"string-similarity": "4.0.4",
|
||||
"swagger-jsdoc": "6.2.8",
|
||||
"swagger-ui-express": "5.0.1",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"twitter-api-v2": "1.15.0",
|
||||
"web-push": "3.6.7",
|
||||
"ws": "8.17.1",
|
||||
"zod": "3.22.3"
|
||||
"zod": "^3.25"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "2.8.17",
|
||||
"@types/react": "18.3.5",
|
||||
"@types/react-dom": "18.3.0",
|
||||
"@types/jsdom": "28.0.1",
|
||||
"@types/jsonwebtoken": "^9.0.0",
|
||||
"@types/lodash": "^4.17.0",
|
||||
"@types/swagger-ui-express": "4.1.8",
|
||||
"@types/web-push": "3.6.4",
|
||||
"@types/ws": "8.5.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.9.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,15 +5,14 @@ import {contact} from 'api/contact'
|
||||
import {createVote} from 'api/create-vote'
|
||||
import {deleteMessage} from 'api/delete-message'
|
||||
import {editMessage} from 'api/edit-message'
|
||||
import {getChannelMemberships} from 'api/get-channel-memberships'
|
||||
import {getLastSeenChannelTime, setChannelLastSeenTime} from 'api/get-channel-seen-time'
|
||||
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 {getLastMessages} from 'api/get-last-messages'
|
||||
import {getMessagesCountEndpoint} from 'api/get-messages-count'
|
||||
import {getOptionsEndpoint} from 'api/get-options'
|
||||
import {getPinnedCompatibilityQuestions} from 'api/get-pinned-compatibility-questions'
|
||||
import {getChannelMessagesEndpoint} from 'api/get-private-messages'
|
||||
import {getUser} from 'api/get-user'
|
||||
import {hideProfile} from 'api/hide-profile'
|
||||
import {reactToMessage} from 'api/react-to-message'
|
||||
@@ -22,12 +21,16 @@ 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 {updateCompatibilityQuestionPin} from 'api/update-compatibility-question-pin'
|
||||
import {updateConnectionInterests} from 'api/update-connection-interests'
|
||||
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 {APIError, APIErrors, pathWithPrefix} from 'common/api/utils'
|
||||
import {sendDiscordMessage} from 'common/discord/core'
|
||||
import {DEPLOYED_WEB_URL} from 'common/envs/constants'
|
||||
import {IS_LOCAL} from 'common/hosting/constants'
|
||||
import {filterDefined} from 'common/util/array'
|
||||
import cors from 'cors'
|
||||
import * as crypto from 'crypto'
|
||||
import express, {type ErrorRequestHandler, type RequestHandler} from 'express'
|
||||
@@ -53,10 +56,12 @@ 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 {createUserAndProfile} from './create-user-and-profile'
|
||||
import {deleteBookmarkedSearch} from './delete-bookmarked-search'
|
||||
import {deleteCompatibilityAnswer} from './delete-compatibility-answer'
|
||||
import {deleteMe} from './delete-me'
|
||||
import {getCompatibilityQuestions} from './get-compatibililty-questions'
|
||||
import {getConnectionInterestsEndpoint} from './get-connection-interests'
|
||||
import {getCurrentPrivateUser} from './get-current-private-user'
|
||||
import {getEvents} from './get-events'
|
||||
import {getLikesAndShips} from './get-likes-and-ships'
|
||||
@@ -65,6 +70,7 @@ import {getNotifications} from './get-notifications'
|
||||
import {getProfileAnswers} from './get-profile-answers'
|
||||
import {getProfiles} from './get-profiles'
|
||||
import {getSupabaseToken} from './get-supabase-token'
|
||||
import {getUserAndProfileHandler} from './get-user-and-profile'
|
||||
import {getUserDataExport} from './get-user-data-export'
|
||||
import {hasFreeLike} from './has-free-like'
|
||||
import {health} from './health'
|
||||
@@ -72,22 +78,26 @@ 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 {llmExtractProfileEndpoint} from './llm-extract-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 {searchLocationEndpoint} 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 {stats} from './stats'
|
||||
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'
|
||||
import {updateProfileEndpoint} from './update-profile'
|
||||
import {updateUserLocale} from './update-user-locale'
|
||||
import {validateUsernameEndpoint} from './validate-username'
|
||||
|
||||
// const corsOptions: CorsOptions = {
|
||||
// origin: ['*'], // Only allow requests from this domain
|
||||
@@ -113,6 +123,8 @@ const requestMonitoring: RequestHandler = (req, _res, next) => {
|
||||
log(`${req.method} ${req.url}`)
|
||||
metrics.inc('http/request_count', {endpoint: req.path})
|
||||
next()
|
||||
// There's a bug worth flagging in that middleware. The timing/cleanup code after next() won't work as you expect:
|
||||
// next() is synchronous — it just hands off to the next middleware. The response hasn't been sent by the time endTs is captured. To measure actual latency you'd want to hook into res.on('finish', ...)
|
||||
const endTs = hrtime.bigint()
|
||||
const latencyMs = Number(endTs - startTs) / 1e6
|
||||
metrics.push('http/request_latency', latencyMs, {endpoint: req.path})
|
||||
@@ -123,16 +135,16 @@ 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}
|
||||
if (error.details != null) {
|
||||
output.details = error.details
|
||||
}
|
||||
res.status(error.code).json(output)
|
||||
res.status(error.code).json(error.toJSON())
|
||||
}
|
||||
} else {
|
||||
log.error(error)
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({message: error.stack, error})
|
||||
const apiError = APIErrors.internalServerError(error.message || 'Internal server error', {
|
||||
originalError: error.toString(),
|
||||
context: 'Unhandled exception in request processing',
|
||||
})
|
||||
res.status(500).json(apiError.toJSON())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,6 +230,28 @@ export function zodToOpenApiSchema(zodObj: ZodTypeAny): any {
|
||||
return schema
|
||||
}
|
||||
|
||||
function inferTag(route: string) {
|
||||
let tag = 'General'
|
||||
if (route.includes('user') || route.includes('profile')) tag = 'Profiles'
|
||||
if (route.includes('auth') || route.includes('login') || route === 'me') tag = 'Authentication'
|
||||
if (route.includes('search') || route.includes('location')) tag = 'Search'
|
||||
if (route.includes('message') || route.includes('channel')) tag = 'Messaging'
|
||||
if (route.includes('compatibility') || route.includes('question')) tag = 'Compatibility'
|
||||
if (
|
||||
route.includes('like') ||
|
||||
route.includes('ship') ||
|
||||
route.includes('star') ||
|
||||
route.includes('block')
|
||||
)
|
||||
tag = 'Relations'
|
||||
if (route.includes('event') || route.includes('rsvp')) tag = 'Events'
|
||||
if (route.includes('notification') || route.includes('notif')) tag = 'Notifications'
|
||||
if (route.includes('comment')) tag = 'Comments'
|
||||
if (route.includes('report') || route.includes('ban')) tag = 'Moderation'
|
||||
if (route.includes('option') || route.includes('locale')) tag = 'Utilities'
|
||||
return tag
|
||||
}
|
||||
|
||||
function generateSwaggerPaths(api: typeof API) {
|
||||
const paths: Record<string, any> = {}
|
||||
|
||||
@@ -226,16 +260,85 @@ function generateSwaggerPaths(api: typeof API) {
|
||||
const method = config.method.toLowerCase()
|
||||
const summary = (config as any).summary ?? route
|
||||
|
||||
// Include props in request body for POST/PUT
|
||||
const tag = (config as any).tag ?? inferTag(route)
|
||||
|
||||
const operation: any = {
|
||||
summary,
|
||||
tags: [(config as any).tag ?? 'API'],
|
||||
description: (config as any).description ?? '',
|
||||
tags: [tag],
|
||||
responses: {
|
||||
200: {
|
||||
description: 'OK',
|
||||
description: 'Success',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {type: 'object'}, // could be improved by introspecting returns
|
||||
schema: {type: 'object'},
|
||||
example: (config as any).exampleResponse ?? {},
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: 'Bad Request - Invalid input or malformed request',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: {type: 'string'},
|
||||
details: {type: 'object'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
401: {
|
||||
description: 'Unauthorized - Authentication required',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: {type: 'string'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
403: {
|
||||
description: 'Forbidden - Insufficient permissions',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: {type: 'string'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: 'Not Found - Resource does not exist',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: {type: 'string'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
500: {
|
||||
description: 'Internal Server Error - Something went wrong on our end',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: {type: 'string'},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -249,6 +352,7 @@ function generateSwaggerPaths(api: typeof API) {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: zodToOpenApiSchema(config.props),
|
||||
example: (config as any).exampleRequest ?? {},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -262,13 +366,16 @@ function generateSwaggerPaths(api: typeof API) {
|
||||
ZodString: 'string',
|
||||
ZodNumber: 'number',
|
||||
ZodBoolean: 'boolean',
|
||||
ZodArray: 'array',
|
||||
}
|
||||
const t = zodType as z.ZodTypeAny // assert type to ZodTypeAny
|
||||
const typeName = t._def.typeName
|
||||
return {
|
||||
name: key,
|
||||
in: 'query',
|
||||
description: (config as any).paramDescriptions?.[key] ?? '',
|
||||
required: !(t.isOptional ?? false),
|
||||
schema: {type: typeMap[t._def.typeName] ?? 'string'},
|
||||
schema: {type: typeMap[typeName] ?? 'string'},
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -285,14 +392,97 @@ function generateSwaggerPaths(api: typeof API) {
|
||||
return paths
|
||||
}
|
||||
|
||||
const apiKey = process.env.NEXT_PUBLIC_FIREBASE_API_KEY ?? 'API_KEY'
|
||||
|
||||
const swaggerDocument: OpenAPIV3.Document = {
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
title: 'Compass API',
|
||||
description: `Compass is a free, open-source platform to help people form deep, meaningful, and lasting connections — whether platonic, romantic, or collaborative. It’s made possible by contributions from the community, including code, ideas, feedback, and donations. Unlike typical apps, Compass prioritizes values, interests, and personality over swipes and ads, giving you full control over who you discover and how you connect.\n Git: ${git.commitDate} (${git.revision}).`,
|
||||
description: `
|
||||
Compass is a free, open-source platform to help people form deep, meaningful, and lasting connections — whether platonic, romantic, or collaborative. Our API provides programmatic access to core platform features including user profiles, messaging, compatibility scoring, and community features.
|
||||
|
||||
## Access Tiers
|
||||
|
||||
### Tier 1 — Public Access (no authentication required)
|
||||
Some endpoints are publicly accessible without authentication, such as events and server health. These are marked as **public** in the endpoint documentation.
|
||||
|
||||
### Tier 2 — User Access (Firebase authentication required)
|
||||
Most endpoints require a valid Firebase JWT token. This gives you access to your own user data, profile, messages, and all interactive features.
|
||||
|
||||
To obtain a token:
|
||||
|
||||
**In your browser console while logged in (CTRL+SHIFT+C, then select the Console tab):**
|
||||
\`\`\`js
|
||||
const db = await new Promise((res, rej) => {
|
||||
const req = indexedDB.open('firebaseLocalStorageDb')
|
||||
req.onsuccess = () => res(req.result)
|
||||
req.onerror = rej
|
||||
})
|
||||
const req = db.transaction('firebaseLocalStorage', 'readonly').objectStore('firebaseLocalStorage').getAll()
|
||||
req.onsuccess = () => {
|
||||
const id_token = req.result[0].value.stsTokenManager.accessToken
|
||||
console.log('YOUR_FIREBASE_JWT_TOKEN is the string below:')
|
||||
console.log(id_token)
|
||||
copy(id_token)
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
**For testing (REST):**
|
||||
\`\`\`bash
|
||||
curl 'https://identitytoolkit.googleapis.com/v1/accounts:signInWithPassword?key=${apiKey}' \\
|
||||
-H 'Content-Type: application/json' \\
|
||||
--data '{"email":"you@example.com","password":"yourpassword","returnSecureToken":true}'
|
||||
# Use the returned idToken as your Bearer token
|
||||
\`\`\`
|
||||
|
||||
Tokens expire after **1 hour**. Refresh by calling \`getIdToken(true)\`.
|
||||
|
||||
Pass the token in the Authorization header for all authenticated requests:
|
||||
\`\`\`
|
||||
Authorization: Bearer YOUR_FIREBASE_JWT_TOKEN
|
||||
\`\`\`
|
||||
|
||||
In the API docs, authenticate through the green button at the bottom right of this section.
|
||||
|
||||
**Don't have an account?** [Register on Compass](${DEPLOYED_WEB_URL}/register) to get started.
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
API requests are subject to rate limiting to ensure fair usage and platform stability. Exceeding limits will result in a \`429 Too Many Requests\` response. Rate limits are applied per authenticated user. Unauthenticated requests are limited by IP.
|
||||
|
||||
## Versioning
|
||||
|
||||
This documentation reflects API version ${pkgVersion}. Endpoints marked as **deprecated** will include a \`Sunset\` header indicating when they will be removed, and a \`Link\` header pointing to the replacement endpoint. Breaking changes are avoided where possible.
|
||||
|
||||
## Error Handling
|
||||
|
||||
All API responses follow a consistent error format:
|
||||
\`\`\`json
|
||||
{
|
||||
"message": "Human-readable error description",
|
||||
"details": { /* Optional additional context */ }
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
Common HTTP status codes:
|
||||
- \`200\` Success
|
||||
- \`400\` Bad Request — invalid or missing input
|
||||
- \`401\` Unauthorized — missing or expired token
|
||||
- \`403\` Forbidden — valid token but insufficient permissions
|
||||
- \`404\` Not Found — resource does not exist
|
||||
- \`429\` Too Many Requests — rate limit exceeded
|
||||
- \`500\` Internal Server Error
|
||||
|
||||
## Open Source
|
||||
|
||||
Compass is open source. Contributions, bug reports, and feature requests are welcome on [GitHub](https://github.com/CompassConnections/Compass).
|
||||
|
||||
## Git Information
|
||||
|
||||
Commit: ${git.revision} (${git.commitDate})`,
|
||||
version: pkgVersion,
|
||||
contact: {
|
||||
name: 'Compass',
|
||||
name: 'Compass Team',
|
||||
email: 'hello@compassmeet.com',
|
||||
url: 'https://compassmeet.com',
|
||||
},
|
||||
@@ -304,14 +494,86 @@ const swaggerDocument: OpenAPIV3.Document = {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
description: 'Firebase JWT token obtained through authentication',
|
||||
},
|
||||
ApiKeyAuth: {
|
||||
type: 'apiKey',
|
||||
in: 'header',
|
||||
name: 'x-api-key',
|
||||
description: 'API key for internal/non-user endpoints',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: filterDefined([
|
||||
{
|
||||
name: 'General',
|
||||
description: 'General endpoints and health checks',
|
||||
},
|
||||
{
|
||||
name: 'Authentication',
|
||||
description: 'User authentication and account management endpoints',
|
||||
},
|
||||
{
|
||||
name: 'Users',
|
||||
description: 'User accounts',
|
||||
},
|
||||
{
|
||||
name: 'Profiles',
|
||||
description: 'User profile creation, retrieval, updating, and deletion',
|
||||
},
|
||||
{
|
||||
name: 'Search',
|
||||
description: 'User discovery and search functionality',
|
||||
},
|
||||
{
|
||||
name: 'Messages',
|
||||
description: 'Direct messaging between users',
|
||||
},
|
||||
{
|
||||
name: 'Compatibility',
|
||||
description: 'Compatibility questions, answers, and scoring',
|
||||
},
|
||||
{
|
||||
name: 'Relations',
|
||||
description: 'User relationships (likes, ships, blocks, comments)',
|
||||
},
|
||||
{
|
||||
name: 'Notifications',
|
||||
description: 'User notifications and preferences',
|
||||
},
|
||||
{
|
||||
name: 'Events',
|
||||
description: 'Community events and RSVP management',
|
||||
},
|
||||
{
|
||||
name: 'Votes',
|
||||
description: 'Voting system for user content and polls',
|
||||
},
|
||||
{
|
||||
name: 'Moderation',
|
||||
description: 'Report system and user moderation',
|
||||
},
|
||||
{
|
||||
name: 'Admin',
|
||||
description: 'Administrative functions including user bans and moderation',
|
||||
},
|
||||
{
|
||||
name: 'Contact',
|
||||
description: 'Contact form and support requests',
|
||||
},
|
||||
{
|
||||
name: 'Utilities',
|
||||
description: 'Helper functions and utilities',
|
||||
},
|
||||
{
|
||||
name: 'Internal',
|
||||
description: 'Internal API endpoints for system operations',
|
||||
},
|
||||
IS_LOCAL && {
|
||||
name: 'Local',
|
||||
description: 'Local development and testing endpoints',
|
||||
},
|
||||
]),
|
||||
} as OpenAPIV3.Document
|
||||
|
||||
// Triggers Missing parameter name at index 3: *; visit https://git.new/pathToRegexpError for info
|
||||
@@ -329,6 +591,7 @@ const handlers: {[k in APIPath]: APIHandler<k>} = {
|
||||
'create-private-user-message-channel': createPrivateUserMessageChannel,
|
||||
'create-profile': createProfile,
|
||||
'create-user': createUser,
|
||||
'create-user-and-profile': createUserAndProfile,
|
||||
'create-vote': createVote,
|
||||
'delete-bookmarked-search': deleteBookmarkedSearch,
|
||||
'delete-compatibility-answer': deleteCompatibilityAnswer,
|
||||
@@ -337,11 +600,12 @@ const handlers: {[k in APIPath]: APIHandler<k>} = {
|
||||
'get-channel-memberships': getChannelMemberships,
|
||||
'get-channel-messages': getChannelMessagesEndpoint,
|
||||
'get-channel-seen-time': getLastSeenChannelTime,
|
||||
'get-last-messages': getLastMessages,
|
||||
'get-compatibility-questions': getCompatibilityQuestions,
|
||||
'get-likes-and-ships': getLikesAndShips,
|
||||
'get-messages-count': getMessagesCount,
|
||||
'get-messages-count': getMessagesCountEndpoint,
|
||||
'get-notifications': getNotifications,
|
||||
'get-options': getOptions,
|
||||
'get-options': getOptionsEndpoint,
|
||||
'get-profile-answers': getProfileAnswers,
|
||||
'get-profiles': getProfiles,
|
||||
'get-supabase-token': getSupabaseToken,
|
||||
@@ -361,7 +625,7 @@ const handlers: {[k in APIPath]: APIHandler<k>} = {
|
||||
'remove-pinned-photo': removePinnedPhoto,
|
||||
'save-subscription': saveSubscription,
|
||||
'save-subscription-mobile': saveSubscriptionMobile,
|
||||
'search-location': searchLocation,
|
||||
'search-location': searchLocationEndpoint,
|
||||
'search-near-city': searchNearCity,
|
||||
'search-users': searchUsers,
|
||||
'set-channel-seen-time': setChannelLastSeenTime,
|
||||
@@ -371,12 +635,19 @@ const handlers: {[k in APIPath]: APIHandler<k>} = {
|
||||
'star-profile': starProfile,
|
||||
'update-notif-settings': updateNotifSettings,
|
||||
'update-options': updateOptions,
|
||||
'update-user-locale': updateUserLocale,
|
||||
'update-private-user-message-channel': updatePrivateUserMessageChannel,
|
||||
'update-profile': updateProfile,
|
||||
'update-profile': updateProfileEndpoint,
|
||||
'update-compatibility-question-pin': updateCompatibilityQuestionPin,
|
||||
'get-pinned-compatibility-questions': getPinnedCompatibilityQuestions,
|
||||
'get-connection-interests': getConnectionInterestsEndpoint,
|
||||
'update-connection-interest': updateConnectionInterests,
|
||||
'user/by-id/:id': getUser,
|
||||
'user/by-id/:id/block': blockUser,
|
||||
'user/by-id/:id/unblock': unblockUser,
|
||||
vote: vote,
|
||||
'validate-username': validateUsernameEndpoint,
|
||||
'llm-extract-profile': llmExtractProfileEndpoint,
|
||||
// 'user/:username': getUser,
|
||||
// 'user/:username/lite': getDisplayUser,
|
||||
// 'user/by-id/:id/lite': getDisplayUser,
|
||||
@@ -387,7 +658,9 @@ const handlers: {[k in APIPath]: APIHandler<k>} = {
|
||||
'rsvp-event': rsvpEvent,
|
||||
'update-event': updateEvent,
|
||||
health: health,
|
||||
stats: stats,
|
||||
me: getMe,
|
||||
'get-user-and-profile': getUserAndProfileHandler,
|
||||
report: report,
|
||||
}
|
||||
|
||||
@@ -398,7 +671,7 @@ Object.entries(handlers).forEach(([path, handler]) => {
|
||||
|
||||
const apiRoute = [
|
||||
url,
|
||||
express.json(),
|
||||
express.json({limit: '1mb'}),
|
||||
allowCorsUnrestricted,
|
||||
cache,
|
||||
typedEndpoint(path as any, handler as any),
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
// const tokens = await tokenRes.json();
|
||||
// if (tokens.error) {
|
||||
// console.error('Google token error:', tokens);
|
||||
// throw new APIError(400, 'Google token error: ' + JSON.stringify(tokens))
|
||||
// throw APIErrors.badRequest('Google token error: ' + JSON.stringify(tokens))
|
||||
// }
|
||||
// console.log('Google Tokens:', tokens);
|
||||
//
|
||||
|
||||
@@ -1,21 +1,17 @@
|
||||
import {APIError, APIHandler} from 'api/helpers/endpoint'
|
||||
import {APIErrors, 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 db = createSupabaseDirectClient()
|
||||
await throwErrorIfNotMod(auth.uid)
|
||||
if (isAdminId(userId)) throw new APIError(403, 'Cannot ban admin')
|
||||
if (isAdminId(userId)) throw APIErrors.forbidden('Cannot ban admin')
|
||||
await trackPublicEvent(auth.uid, 'ban user', {
|
||||
userId,
|
||||
})
|
||||
await updateUser(db, userId, {
|
||||
isBannedFromPosting: !unban,
|
||||
})
|
||||
await updateUser(userId, {isBannedFromPosting: !unban})
|
||||
log('updated user')
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@ import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {updatePrivateUser} from 'shared/supabase/users'
|
||||
import {FieldVal} from 'shared/supabase/utils'
|
||||
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {APIErrors, 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')
|
||||
if (auth.uid === id) throw APIErrors.badRequest('You cannot block yourself')
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
await pg.tx(async (tx) => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {APIError, APIHandler} from 'api/helpers/endpoint'
|
||||
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
|
||||
import {tryCatch} from 'common/util/try-catch'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {update} from 'shared/supabase/utils'
|
||||
@@ -19,15 +19,15 @@ export const cancelEvent: APIHandler<'cancel-event'> = async (body, auth) => {
|
||||
)
|
||||
|
||||
if (!event) {
|
||||
throw new APIError(404, 'Event not found')
|
||||
throw APIErrors.notFound('Event not found')
|
||||
}
|
||||
|
||||
if (event.creator_id !== auth.uid) {
|
||||
throw new APIError(403, 'Only the event creator can cancel this event')
|
||||
throw APIErrors.forbidden('Only the event creator can cancel this event')
|
||||
}
|
||||
|
||||
if (event.status === 'cancelled') {
|
||||
throw new APIError(400, 'Event is already cancelled')
|
||||
throw APIErrors.badRequest('Event is already cancelled')
|
||||
}
|
||||
|
||||
// Update event status to cancelled
|
||||
@@ -39,7 +39,7 @@ export const cancelEvent: APIHandler<'cancel-event'> = async (body, auth) => {
|
||||
)
|
||||
|
||||
if (error) {
|
||||
throw new APIError(500, 'Failed to cancel event: ' + error.message)
|
||||
throw APIErrors.internalServerError('Failed to cancel event: ' + error.message)
|
||||
}
|
||||
|
||||
return {success: true}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {APIError, APIHandler} from 'api/helpers/endpoint'
|
||||
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
|
||||
import {tryCatch} from 'common/util/try-catch'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
@@ -17,7 +17,7 @@ export const cancelRsvp: APIHandler<'cancel-rsvp'> = async (body, auth) => {
|
||||
)
|
||||
|
||||
if (!rsvp) {
|
||||
throw new APIError(404, 'RSVP not found')
|
||||
throw APIErrors.notFound('RSVP not found')
|
||||
}
|
||||
|
||||
// Delete the RSVP
|
||||
@@ -31,7 +31,7 @@ export const cancelRsvp: APIHandler<'cancel-rsvp'> = async (body, auth) => {
|
||||
)
|
||||
|
||||
if (error) {
|
||||
throw new APIError(500, 'Failed to cancel RSVP: ' + error.message)
|
||||
throw APIErrors.internalServerError('Failed to cancel RSVP: ' + error.message)
|
||||
}
|
||||
|
||||
return {success: true}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {tryCatch} from 'common/util/try-catch'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {insert} from 'shared/supabase/utils'
|
||||
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {APIErrors, 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`.
|
||||
@@ -19,7 +19,7 @@ export const contact: APIHandler<'contact'> = async ({content, userId}, _auth) =
|
||||
}),
|
||||
)
|
||||
|
||||
if (error) throw new APIError(500, 'Failed to submit contact message')
|
||||
if (error) throw APIErrors.internalServerError('Failed to submit contact message')
|
||||
|
||||
const continuation = async () => {
|
||||
try {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {type JSONContent} from '@tiptap/core'
|
||||
import {APIError, APIHandler} from 'api/helpers/endpoint'
|
||||
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
|
||||
import {Notification} from 'common/notifications'
|
||||
import {convertComment} from 'common/supabase/comment'
|
||||
import {type Row} from 'common/supabase/utils'
|
||||
@@ -8,7 +8,7 @@ import {getNotificationDestinationsForUser} from 'common/user-notification-prefe
|
||||
import {richTextToString} from 'common/util/parse'
|
||||
import * as crypto from 'crypto'
|
||||
import {sendNewEndorsementEmail} from 'email/functions/helpers'
|
||||
import {createSupabaseDirectClient, SupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {insertNotificationToSupabase} from 'shared/supabase/notifications'
|
||||
import {getPrivateUser, getUser} from 'shared/utils'
|
||||
import {broadcastUpdatedComment} from 'shared/websockets/helpers'
|
||||
@@ -22,7 +22,7 @@ export const createComment: APIHandler<'create-comment'> = async (
|
||||
const {creator, content} = await validateComment(userId, auth.uid, submittedContent)
|
||||
|
||||
const onUser = await getUser(userId)
|
||||
if (!onUser) throw new APIError(404, 'User not found')
|
||||
if (!onUser) throw APIErrors.notFound('User not found')
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
const comment = await pg.one<Row<'profile_comments'>>(
|
||||
@@ -44,7 +44,6 @@ export const createComment: APIHandler<'create-comment'> = async (
|
||||
creator,
|
||||
richTextToString(content),
|
||||
comment.id,
|
||||
pg,
|
||||
)
|
||||
|
||||
broadcastUpdatedComment(convertComment(comment))
|
||||
@@ -55,18 +54,17 @@ export const createComment: APIHandler<'create-comment'> = async (
|
||||
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')
|
||||
if (creator.isBannedFromPosting) throw new APIError(403, 'You are banned')
|
||||
if (!creator) throw APIErrors.unauthorized('Your account was not found')
|
||||
if (creator.isBannedFromPosting) throw APIErrors.forbidden('You are banned')
|
||||
|
||||
const otherUser = await getPrivateUser(userId)
|
||||
if (!otherUser) throw new APIError(404, 'Other user not found')
|
||||
if (!otherUser) throw APIErrors.notFound('Other user not found')
|
||||
if (otherUser.blockedUserIds.includes(creatorId)) {
|
||||
throw new APIError(404, 'User has blocked you')
|
||||
throw APIErrors.notFound('User has blocked you')
|
||||
}
|
||||
|
||||
if (JSON.stringify(content).length > MAX_COMMENT_JSON_LENGTH) {
|
||||
throw new APIError(
|
||||
400,
|
||||
throw APIErrors.badRequest(
|
||||
`Comment is too long; should be less than ${MAX_COMMENT_JSON_LENGTH} as a JSON string.`,
|
||||
)
|
||||
}
|
||||
@@ -78,7 +76,6 @@ const createNewCommentOnProfileNotification = async (
|
||||
creator: User,
|
||||
sourceText: string,
|
||||
commentId: number,
|
||||
pg: SupabaseDirectClient,
|
||||
) => {
|
||||
const privateUser = await getPrivateUser(onUser.id)
|
||||
if (!privateUser) return
|
||||
@@ -104,7 +101,7 @@ const createNewCommentOnProfileNotification = async (
|
||||
sourceSlug: onUser.username,
|
||||
}
|
||||
if (sendToBrowser) {
|
||||
await insertNotificationToSupabase(notification, pg)
|
||||
await insertNotificationToSupabase(notification)
|
||||
}
|
||||
if (sendToMobile) {
|
||||
// await createPushNotification(
|
||||
|
||||
@@ -3,14 +3,14 @@ import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {insert} from 'shared/supabase/utils'
|
||||
import {getUser} from 'shared/utils'
|
||||
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {APIErrors, 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')
|
||||
if (!creator) throw APIErrors.unauthorized('Your account was not found')
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
@@ -23,7 +23,7 @@ export const createCompatibilityQuestion: APIHandler<'create-compatibility-quest
|
||||
}),
|
||||
)
|
||||
|
||||
if (error) throw new APIError(401, 'Error creating question')
|
||||
if (error) throw APIErrors.internalServerError('Error creating question')
|
||||
|
||||
return {question: data}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import {APIError, APIHandler} from 'api/helpers/endpoint'
|
||||
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
|
||||
import {sendDiscordMessage} from 'common/discord/core'
|
||||
import {DEPLOYED_WEB_URL} from 'common/envs/constants'
|
||||
import {tryCatch} from 'common/util/try-catch'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {insert} from 'shared/supabase/utils'
|
||||
@@ -8,22 +10,22 @@ export const createEvent: APIHandler<'create-event'> = async (body, auth) => {
|
||||
|
||||
// Validate location
|
||||
if (body.locationType === 'in_person' && !body.locationAddress) {
|
||||
throw new APIError(400, 'In-person events require a location address')
|
||||
throw APIErrors.badRequest('In-person events require a location address')
|
||||
}
|
||||
if (body.locationType === 'online' && !body.locationUrl) {
|
||||
throw new APIError(400, 'Online events require a location URL')
|
||||
throw APIErrors.badRequest('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')
|
||||
throw APIErrors.badRequest('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')
|
||||
throw APIErrors.badRequest('Event end time must be after start time')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,12 +40,28 @@ export const createEvent: APIHandler<'create-event'> = async (body, auth) => {
|
||||
event_start_time: body.eventStartTime,
|
||||
event_end_time: body.eventEndTime,
|
||||
max_participants: body.maxParticipants,
|
||||
}),
|
||||
}), // consider using convertObjectToSQLRow() to convert snake case to camel case
|
||||
)
|
||||
|
||||
if (error) {
|
||||
throw new APIError(500, 'Failed to create event: ' + error.message)
|
||||
throw APIErrors.internalServerError('Failed to create event: ' + error.message)
|
||||
}
|
||||
|
||||
return {success: true, event: data}
|
||||
const continuation = async () => {
|
||||
try {
|
||||
const user = await pg.oneOrNone(`select name from users where id = $1 `, [auth.uid])
|
||||
const message: string = `${user.name} created a new [event](${DEPLOYED_WEB_URL}/events)!\n**${body.title}**\n${body.description}\nStart: ${body.eventStartTime.replace('T', ' @ ').replace('.000Z', ' UTC')}`
|
||||
await sendDiscordMessage(message, 'general')
|
||||
} catch (e) {
|
||||
console.error('Failed to send discord event', e)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
result: {
|
||||
success: true,
|
||||
event: data,
|
||||
},
|
||||
continue: continuation,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,14 @@ import {Notification} from 'common/notifications'
|
||||
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'
|
||||
import {
|
||||
createBulkNotification,
|
||||
insertNotificationToSupabase,
|
||||
NotificationTemplateTranslation,
|
||||
} from 'shared/supabase/notifications'
|
||||
|
||||
const COMPASS_LOGO_URL =
|
||||
'https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/misc%2Fcompass-192.png?alt=media&token=9fd251c5-fc43-4375-b629-1a8f4bbe8185'
|
||||
|
||||
export const createAndroidReleaseNotifications = async () => {
|
||||
const createdTime = Date.now()
|
||||
@@ -16,8 +23,7 @@ export const createAndroidReleaseNotifications = async () => {
|
||||
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',
|
||||
sourceUserAvatarUrl: COMPASS_LOGO_URL,
|
||||
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.',
|
||||
@@ -36,8 +42,7 @@ 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: COMPASS_LOGO_URL,
|
||||
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.',
|
||||
@@ -125,38 +130,16 @@ export const createNotification = async (
|
||||
* 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,
|
||||
)
|
||||
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: COMPASS_LOGO_URL,
|
||||
sourceUpdateType: 'created',
|
||||
})
|
||||
|
||||
console.log(`Created events notification template ${templateId} for ${count} users`)
|
||||
|
||||
@@ -166,3 +149,61 @@ export const createEventsAvailableNotifications = async () => {
|
||||
userCount: count,
|
||||
}
|
||||
}
|
||||
|
||||
export const createSomeNotifications = async () => {
|
||||
const translations: Omit<NotificationTemplateTranslation, 'template_id' | 'created_time'>[] = [
|
||||
// French translation
|
||||
{
|
||||
locale: 'fr',
|
||||
title: 'Bonjour',
|
||||
source_text: "C'est une notif",
|
||||
},
|
||||
// German translation
|
||||
{
|
||||
locale: 'de',
|
||||
title: 'Halo',
|
||||
source_text: 'Dis das',
|
||||
},
|
||||
]
|
||||
|
||||
// Create template with translations
|
||||
const {templateId, count} = await createBulkNotification(
|
||||
{
|
||||
sourceType: 'hello',
|
||||
title: 'Hello world',
|
||||
sourceText: 'This is a notification',
|
||||
sourceSlug: '/settings',
|
||||
sourceUserAvatarUrl: COMPASS_LOGO_URL,
|
||||
sourceUpdateType: 'created',
|
||||
},
|
||||
translations,
|
||||
)
|
||||
console.log(`Created some notification template ${templateId} for ${count} users`)
|
||||
}
|
||||
|
||||
export const createInterestIndicatorNotifications = async () => {
|
||||
const translations: Omit<NotificationTemplateTranslation, 'template_id' | 'created_time'>[] = [
|
||||
// French translation
|
||||
{
|
||||
locale: 'fr',
|
||||
title: 'Nouveau : Signaux d’intérêt privés',
|
||||
source_text:
|
||||
'Vous pouvez désormais exprimer votre intérêt en privé à la fin de chaque profil. L’autre personne n’est informée que si l’intérêt est réciproque.',
|
||||
},
|
||||
]
|
||||
|
||||
// Create template with translations
|
||||
const {templateId, count} = await createBulkNotification(
|
||||
{
|
||||
sourceType: 'info',
|
||||
title: 'New: Private interest signals',
|
||||
sourceText:
|
||||
'You can now express interest privately at the end of each profile. The other person is only notified if it’s mutual.',
|
||||
sourceSlug: '/',
|
||||
sourceUserAvatarUrl: COMPASS_LOGO_URL,
|
||||
sourceUpdateType: 'created',
|
||||
},
|
||||
translations,
|
||||
)
|
||||
console.log(`Created some notification template ${templateId} for ${count} users`)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {APIError, APIHandler} from 'api/helpers/endpoint'
|
||||
import {getConnectionInterests} from 'api/get-connection-interests'
|
||||
import {APIErrors, 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 {getProfile} from 'shared/profiles/supabase'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {getPrivateUser, getUser} from 'shared/utils'
|
||||
|
||||
@@ -13,7 +15,7 @@ export const createPrivateUserMessageChannel: APIHandler<
|
||||
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.')
|
||||
throw APIErrors.forbidden('You must verify your email to contact people.')
|
||||
}
|
||||
|
||||
const userIds = uniq(body.userIds.concat(auth.uid))
|
||||
@@ -22,13 +24,12 @@ export const createPrivateUserMessageChannel: APIHandler<
|
||||
const creatorId = auth.uid
|
||||
|
||||
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')
|
||||
if (!creator) throw APIErrors.unauthorized('Your account was not found')
|
||||
if (creator.isBannedFromPosting) throw APIErrors.forbidden('You are banned')
|
||||
const toPrivateUsers = filterDefined(await Promise.all(userIds.map((id) => getPrivateUser(id))))
|
||||
|
||||
if (toPrivateUsers.length !== userIds.length)
|
||||
throw new APIError(
|
||||
404,
|
||||
throw APIErrors.notFound(
|
||||
`Private user ${userIds.find(
|
||||
(uid) => !toPrivateUsers.map((p: any) => p.id).includes(uid),
|
||||
)} not found`,
|
||||
@@ -39,7 +40,21 @@ export const createPrivateUserMessageChannel: APIHandler<
|
||||
user.blockedUserIds.some((blockedId: string) => userIds.includes(blockedId)),
|
||||
)
|
||||
) {
|
||||
throw new APIError(403, 'One of the users has blocked another user in the list')
|
||||
throw APIErrors.forbidden('One of the users has blocked another user in the list')
|
||||
}
|
||||
|
||||
for (const u of toPrivateUsers) {
|
||||
const p = await getProfile(u.id)
|
||||
if (p && !p.allow_direct_messaging) {
|
||||
const {interests, targetInterests} = await getConnectionInterests(
|
||||
{targetUserId: u.id},
|
||||
auth.uid,
|
||||
)
|
||||
const matches = interests.filter((interest: string[]) => targetInterests.includes(interest))
|
||||
if (matches.length > 0) continue
|
||||
const failedUser = await getUser(u.id)
|
||||
throw APIErrors.forbidden(`${failedUser?.username} has disabled direct messaging`)
|
||||
}
|
||||
}
|
||||
|
||||
const currentChannel = await pg.oneOrNone(
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {MAX_COMMENT_JSON_LENGTH} from 'api/create-comment'
|
||||
import {APIError, APIHandler} from 'api/helpers/endpoint'
|
||||
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
|
||||
import {createPrivateUserMessageMain} from 'api/helpers/private-messages'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {getUser} from 'shared/utils'
|
||||
@@ -10,12 +10,12 @@ export const createPrivateUserMessage: APIHandler<'create-private-user-message'>
|
||||
) => {
|
||||
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 APIErrors.badRequest(`Message JSON should be less than ${MAX_COMMENT_JSON_LENGTH}`)
|
||||
}
|
||||
|
||||
const creator = await getUser(auth.uid)
|
||||
if (!creator) throw new APIError(401, 'Your account was not found')
|
||||
if (creator.isBannedFromPosting) throw new APIError(403, 'You are banned')
|
||||
if (!creator) throw APIErrors.unauthorized('Your account was not found')
|
||||
if (creator.isBannedFromPosting) throw APIErrors.forbidden('You are banned')
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
return await createPrivateUserMessageMain(creator, channelId, content, pg, 'private')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {APIError, APIHandler} from 'api/helpers/endpoint'
|
||||
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
|
||||
import {sendDiscordMessage} from 'common/discord/core'
|
||||
import {debug} from 'common/logger'
|
||||
import {jsonToMarkdown} from 'common/md'
|
||||
import {trimStrings} from 'common/parsing'
|
||||
import {HOUR_MS, MINUTE_MS, sleep} from 'common/util/time'
|
||||
@@ -7,37 +8,37 @@ 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 {updateUserData} 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(
|
||||
const {data: existingProfile} = 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')
|
||||
if (existingProfile) {
|
||||
throw APIErrors.badRequest('Profile 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) throw APIErrors.unauthorized('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})
|
||||
updateUserData(pg, auth.uid, {avatarUrl: body.pinned_url || undefined})
|
||||
}
|
||||
|
||||
console.debug('body', body)
|
||||
debug('body', body)
|
||||
|
||||
const {data, error} = await tryCatch(insert(pg, 'profiles', {user_id: auth.uid, ...body}))
|
||||
|
||||
if (error) {
|
||||
log.error('Error creating user: ' + error.message)
|
||||
throw new APIError(500, 'Error creating user')
|
||||
throw APIErrors.internalServerError('Error creating user')
|
||||
}
|
||||
|
||||
log('Created profile', data)
|
||||
@@ -53,7 +54,7 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
|
||||
// So we can sse their full profile as soon as we get the notif on discord. And that allows OG to pull their pic for the link preview.
|
||||
// Regardless, you need to wait for at least 5 seconds that the profile is fully in the db—otherwise ISR may cache "profile not created yet"
|
||||
await sleep(10 * MINUTE_MS)
|
||||
let message: string = `[**${user.name}**](https://www.compassmeet.com/${user.username}) just created a profile`
|
||||
let message: string = `[**${user.name}**](https://compassmeet.com/${user.username}) just created a profile`
|
||||
if (body.bio) {
|
||||
const bioText = jsonToMarkdown(body.bio)
|
||||
if (bioText) message += `\n${bioText}`
|
||||
@@ -73,7 +74,7 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
|
||||
n % 50 === 0
|
||||
)
|
||||
}
|
||||
console.debug(nProfiles, isMilestone(nProfiles))
|
||||
debug(nProfiles, isMilestone(nProfiles))
|
||||
if (isMilestone(nProfiles)) {
|
||||
await sendDiscordMessage(`We just reached **${nProfiles}** total profiles! 🎉`, 'general')
|
||||
}
|
||||
|
||||
218
backend/api/src/create-user-and-profile.ts
Normal file
218
backend/api/src/create-user-and-profile.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import {setLastOnlineTimeUser} from 'api/set-last-online-time'
|
||||
import {setProfileOptions} from 'api/update-options'
|
||||
import {APIErrors} from 'common/api/utils'
|
||||
import {defaultLocale} from 'common/constants'
|
||||
import {sendDiscordMessage} from 'common/discord/core'
|
||||
import {DEPLOYED_WEB_URL} from 'common/envs/constants'
|
||||
import {debug} from 'common/logger'
|
||||
import {trimStrings} from 'common/parsing'
|
||||
import {convertPrivateUser, convertUser} from 'common/supabase/users'
|
||||
import {PrivateUser} from 'common/user'
|
||||
import {getDefaultNotificationPreferences} from 'common/user-notification-preferences'
|
||||
import {cleanDisplayName} from 'common/util/clean-username'
|
||||
import {removeUndefinedProps} from 'common/util/object'
|
||||
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 {removePinnedUrlFromPhotoUrls} from 'shared/profiles/parse-photos'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {insert} from 'shared/supabase/utils'
|
||||
import {getUserByUsername, log} from 'shared/utils'
|
||||
|
||||
import {APIHandler} from './helpers/endpoint'
|
||||
import {validateUsername} from './validate-username'
|
||||
|
||||
export const createUserAndProfile: APIHandler<'create-user-and-profile'> = async (
|
||||
props,
|
||||
auth,
|
||||
req,
|
||||
) => {
|
||||
trimStrings(props)
|
||||
const {
|
||||
deviceToken,
|
||||
locale = defaultLocale,
|
||||
username,
|
||||
name,
|
||||
profile,
|
||||
interests,
|
||||
causes,
|
||||
work,
|
||||
} = props
|
||||
await removePinnedUrlFromPhotoUrls(profile)
|
||||
|
||||
// const host = req.get('referer')
|
||||
|
||||
const ip = getIp(req)
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const cleanName = cleanDisplayName(name || 'User')
|
||||
|
||||
const fbUser = await admin.auth().getUser(auth.uid)
|
||||
const email = fbUser.email
|
||||
|
||||
const bucket = getBucket()
|
||||
const avatarUrl = profile.pinned_url ?? (await generateAvatarUrl(auth.uid, cleanName, bucket))
|
||||
|
||||
let finalUsername = username
|
||||
const validation = await validateUsername(username)
|
||||
if (validation.suggestedUsername) {
|
||||
finalUsername = validation.suggestedUsername
|
||||
} else if (!validation.valid) {
|
||||
throw APIErrors.badRequest(validation.message || 'Invalid username', {
|
||||
field: 'username',
|
||||
resolution:
|
||||
'Usernames must be 3–25 characters and contain only letters, numbers, or underscores.',
|
||||
})
|
||||
}
|
||||
|
||||
// The pg.tx() call wraps several database operations in a single atomic transaction,
|
||||
// ensuring they either all succeed or all fail together.
|
||||
const {user, privateUser, newProfileRow} = await pg.tx(async (tx) => {
|
||||
const existingUser = await tx.oneOrNone('select id from users where id = $1', [auth.uid])
|
||||
if (existingUser) {
|
||||
const existingProfile = await tx.oneOrNone('select id from profiles where user_id = $1', [
|
||||
auth.uid,
|
||||
])
|
||||
if (existingProfile) {
|
||||
throw APIErrors.conflict('An account for this user already exists', {
|
||||
resolution:
|
||||
'If you already have an account, try logging in. If you believe this is a mistake, contact support.',
|
||||
})
|
||||
} else {
|
||||
await pg.none('DELETE FROM users WHERE id = $1', [auth.uid])
|
||||
}
|
||||
}
|
||||
|
||||
const sameNameUser = await getUserByUsername(finalUsername, tx)
|
||||
if (sameNameUser) {
|
||||
throw APIErrors.conflict('Username is already taken', {
|
||||
field: 'username',
|
||||
resolution: 'Please choose a different username.',
|
||||
})
|
||||
}
|
||||
|
||||
const privateUserData: PrivateUser = {
|
||||
id: auth.uid,
|
||||
email,
|
||||
locale,
|
||||
initialIpAddress: ip,
|
||||
initialDeviceToken: deviceToken,
|
||||
notificationPreferences: getDefaultNotificationPreferences(),
|
||||
blockedUserIds: [],
|
||||
blockedByUserIds: [],
|
||||
}
|
||||
|
||||
const newUserRow = await insert(tx, 'users', {
|
||||
id: auth.uid,
|
||||
name: cleanName,
|
||||
username: finalUsername,
|
||||
avatar_url: avatarUrl,
|
||||
is_banned_from_posting: Boolean(
|
||||
(deviceToken && bannedDeviceTokens.includes(deviceToken)) ||
|
||||
(ip && bannedIpAddresses.includes(ip)),
|
||||
),
|
||||
data: {},
|
||||
})
|
||||
|
||||
const newPrivateUserRow = await insert(tx, 'private_users', {
|
||||
id: privateUserData.id,
|
||||
data: privateUserData,
|
||||
})
|
||||
|
||||
const profileData = removeUndefinedProps(profile)
|
||||
|
||||
const newProfileRow = await insert(tx, 'profiles', {
|
||||
user_id: auth.uid,
|
||||
...profileData,
|
||||
})
|
||||
|
||||
const profileId = newProfileRow.id
|
||||
|
||||
await setProfileOptions(tx, profileId, auth.uid, 'interests', interests)
|
||||
await setProfileOptions(tx, profileId, auth.uid, 'causes', causes)
|
||||
await setProfileOptions(tx, profileId, auth.uid, 'work', work)
|
||||
|
||||
return {
|
||||
user: convertUser(newUserRow),
|
||||
privateUser: convertPrivateUser(newPrivateUserRow),
|
||||
newProfileRow,
|
||||
}
|
||||
})
|
||||
|
||||
log('created user and profile', {username: user.username, firebaseId: auth.uid})
|
||||
|
||||
const continuation = async () => {
|
||||
try {
|
||||
await track(auth.uid, 'create profile', {username: user.username})
|
||||
} catch (e) {
|
||||
console.error('Failed to track create profile', e)
|
||||
}
|
||||
try {
|
||||
await sendWelcomeEmail(user, privateUser)
|
||||
} catch (e) {
|
||||
console.error('Failed to sendWelcomeEmail', e)
|
||||
}
|
||||
try {
|
||||
await setLastOnlineTimeUser(auth.uid)
|
||||
} catch (e) {
|
||||
console.error('Failed to set last online time', e)
|
||||
}
|
||||
try {
|
||||
const message: string = `[**${user.name}**](${DEPLOYED_WEB_URL}/${user.username}) just created a profile`
|
||||
await sendDiscordMessage(message, 'members')
|
||||
} catch (e) {
|
||||
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 isMilestone = (n: number) => {
|
||||
return (
|
||||
[15, 20, 30, 40].includes(n) || // early milestones
|
||||
n % 50 === 0
|
||||
)
|
||||
}
|
||||
debug(nProfiles, isMilestone(nProfiles))
|
||||
if (isMilestone(nProfiles)) {
|
||||
await sendDiscordMessage(`We just reached **${nProfiles}** total profiles! 🎉`, 'general')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to send discord user milestone', e)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
result: {
|
||||
// include everything the frontend needs
|
||||
user,
|
||||
privateUser,
|
||||
profile: {
|
||||
...newProfileRow,
|
||||
interests: interests ?? [],
|
||||
causes: causes ?? [],
|
||||
work: work ?? [],
|
||||
},
|
||||
},
|
||||
continue: continuation,
|
||||
}
|
||||
}
|
||||
|
||||
const bannedDeviceTokens = [
|
||||
'fa807d664415',
|
||||
'dcf208a11839',
|
||||
'bbf18707c15d',
|
||||
'4c2d15a6cc0c',
|
||||
'0da6b4ea79d3',
|
||||
]
|
||||
const bannedIpAddresses: string[] = [
|
||||
'24.176.214.250',
|
||||
'2607:fb90:bd95:dbcd:ac39:6c97:4e35:3fed',
|
||||
'2607:fb91:389:ddd0:ac39:8397:4e57:f060',
|
||||
'2607:fb90:ed9a:4c8f:ac39:cf57:4edd:4027',
|
||||
'2607:fb90:bd36:517a:ac39:6c91:812c:6328',
|
||||
]
|
||||
@@ -1,6 +1,6 @@
|
||||
import {setLastOnlineTimeUser} from 'api/set-last-online-time'
|
||||
import {defaultLocale} from 'common/constants'
|
||||
import {RESERVED_PATHS} from 'common/envs/constants'
|
||||
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'
|
||||
@@ -16,31 +16,36 @@ import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {insert} from 'shared/supabase/utils'
|
||||
import {getUser, getUserByUsername, log} from 'shared/utils'
|
||||
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {APIErrors, APIHandler} from './helpers/endpoint'
|
||||
|
||||
/**
|
||||
* Create User API Handler
|
||||
*
|
||||
* Creates a new user account with associated profile and private user data.
|
||||
* This endpoint is called after Firebase authentication to initialize
|
||||
* the user's presence in the Compass database.
|
||||
*
|
||||
* Process:
|
||||
* 1. Validates Firebase authentication token
|
||||
* 2. Creates user record in users table
|
||||
* 3. Creates private user record in private_users table
|
||||
* 4. Generates default profile data
|
||||
* 5. Sends welcome email asynchronously
|
||||
* 6. Tracks user creation event
|
||||
*
|
||||
* @param props - Request parameters including device token and locale
|
||||
* @param auth - Authenticated user information from Firebase
|
||||
* @param req - Express request object for accessing headers/IP
|
||||
* @returns User and private user objects with continuation function for async tasks
|
||||
* @throws {APIError} 403 if user already exists or username is taken
|
||||
*/
|
||||
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'
|
||||
|
||||
// if (
|
||||
// testUserAKAEmailPasswordUser &&
|
||||
// adminToken !== process.env.TEST_CREATE_USER_KEY
|
||||
// ) {
|
||||
// throw new APIError(
|
||||
// 401,
|
||||
// 'Must use correct TEST_CREATE_USER_KEY to create user with email/password'
|
||||
// )
|
||||
// }
|
||||
const {deviceToken, locale = defaultLocale} = props
|
||||
|
||||
const host = req.get('referer')
|
||||
log(`Create user from: ${host}`)
|
||||
log(`Create user from: ${host}, ${props}`)
|
||||
|
||||
const ip = getIp(req)
|
||||
const deviceToken = testUserAKAEmailPasswordUser
|
||||
? randomString() + randomString()
|
||||
: preDeviceToken
|
||||
|
||||
const fbUser = await admin.auth().getUser(auth.uid)
|
||||
const email = fbUser.email
|
||||
@@ -50,9 +55,7 @@ export const createUser: APIHandler<'create-user'> = async (props, auth, req) =>
|
||||
const name = cleanDisplayName(rawName)
|
||||
|
||||
const bucket = getBucket()
|
||||
const avatarUrl = fbUser.photoURL
|
||||
? fbUser.photoURL
|
||||
: await generateAvatarUrl(auth.uid, name, bucket)
|
||||
const avatarUrl = fbUser.photoURL ?? (await generateAvatarUrl(auth.uid, name, bucket))
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
@@ -67,23 +70,28 @@ export const createUser: APIHandler<'create-user'> = async (props, auth, req) =>
|
||||
(r) => r.count,
|
||||
)
|
||||
const usernameExists = dupes > 0
|
||||
const isReservedName = RESERVED_PATHS.includes(username)
|
||||
const isReservedName = RESERVED_PATHS.has(username)
|
||||
if (usernameExists || isReservedName) username += randomString(4)
|
||||
|
||||
const {user, privateUser} = await pg.tx(async (tx) => {
|
||||
const preexistingUser = await getUser(auth.uid, tx)
|
||||
if (preexistingUser)
|
||||
throw new APIError(403, 'User already exists', {
|
||||
userId: auth.uid,
|
||||
throw APIErrors.forbidden('An account for this user already exists', {
|
||||
field: 'userId',
|
||||
context: `User with ID ${auth.uid} already exists`,
|
||||
})
|
||||
|
||||
// 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 APIErrors.conflict('Username is already taken', {
|
||||
field: 'username',
|
||||
context: `Username "${username}" is already taken`,
|
||||
})
|
||||
|
||||
const user = removeUndefinedProps({
|
||||
avatarUrl,
|
||||
isBannedFromPosting: Boolean(
|
||||
is_banned_from_posting: Boolean(
|
||||
(deviceToken && bannedDeviceTokens.includes(deviceToken)) ||
|
||||
(ip && bannedIpAddresses.includes(ip)),
|
||||
),
|
||||
@@ -93,6 +101,7 @@ export const createUser: APIHandler<'create-user'> = async (props, auth, req) =>
|
||||
const privateUser: PrivateUser = {
|
||||
id: auth.uid,
|
||||
email,
|
||||
locale,
|
||||
initialIpAddress: ip,
|
||||
initialDeviceToken: deviceToken,
|
||||
notificationPreferences: getDefaultNotificationPreferences(),
|
||||
@@ -127,7 +136,7 @@ export const createUser: APIHandler<'create-user'> = async (props, auth, req) =>
|
||||
console.error('Failed to track create profile', e)
|
||||
}
|
||||
try {
|
||||
if (!IS_LOCAL) await sendWelcomeEmail(user, privateUser)
|
||||
await sendWelcomeEmail(user, privateUser)
|
||||
} catch (e) {
|
||||
console.error('Failed to sendWelcomeEmail', e)
|
||||
}
|
||||
|
||||
@@ -3,14 +3,14 @@ import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {insert} from 'shared/supabase/utils'
|
||||
import {getUser} from 'shared/utils'
|
||||
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {APIErrors, 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')
|
||||
if (!creator) throw APIErrors.unauthorized('Your account was not found')
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
@@ -24,7 +24,7 @@ export const createVote: APIHandler<'create-vote'> = async (
|
||||
}),
|
||||
)
|
||||
|
||||
if (error) throw new APIError(401, 'Error creating question')
|
||||
if (error) throw APIErrors.unauthorized('Error creating question')
|
||||
|
||||
return {data}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {APIHandler} from 'api/helpers/endpoint'
|
||||
import {APIError} from 'common/api/utils'
|
||||
import {recomputeCompatibilityScoresForUser} from 'shared/compatibility/compute-scores'
|
||||
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
|
||||
import {
|
||||
recomputeCompatibilityScoresForUser,
|
||||
updateCompatibilityPromptsMetrics,
|
||||
} from 'shared/compatibility/compute-scores'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
export const deleteCompatibilityAnswer: APIHandler<'delete-compatibility-answer'> = async (
|
||||
@@ -19,9 +21,11 @@ export const deleteCompatibilityAnswer: APIHandler<'delete-compatibility-answer'
|
||||
)
|
||||
|
||||
if (!item) {
|
||||
throw new APIError(404, 'Item not found')
|
||||
throw APIErrors.notFound('Item not found')
|
||||
}
|
||||
|
||||
const questionId = item.question_id
|
||||
|
||||
// Delete the answer
|
||||
await pg.none(
|
||||
`DELETE
|
||||
@@ -32,12 +36,12 @@ export const deleteCompatibilityAnswer: APIHandler<'delete-compatibility-answer'
|
||||
)
|
||||
|
||||
const continuation = async () => {
|
||||
// Recompute precomputed compatibility scores for this user
|
||||
await recomputeCompatibilityScoresForUser(auth.uid, pg)
|
||||
await updateCompatibilityPromptsMetrics(questionId)
|
||||
await recomputeCompatibilityScoresForUser(auth.uid)
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
result: {status: 'success'},
|
||||
continue: continuation,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import {debug} from 'common/logger'
|
||||
import * as admin from 'firebase-admin'
|
||||
import {deleteUserFiles} from 'shared/firebase-utils'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {getUser} from 'shared/utils'
|
||||
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {APIErrors, 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')
|
||||
throw APIErrors.unauthorized('Your account was not found')
|
||||
}
|
||||
const userId = user.id
|
||||
if (!userId) {
|
||||
throw new APIError(400, 'Invalid user ID')
|
||||
throw APIErrors.badRequest('Invalid user ID')
|
||||
}
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
@@ -42,7 +43,7 @@ export const deleteMe: APIHandler<'me/delete'> = async ({reasonCategory, reasonD
|
||||
try {
|
||||
const auth = admin.auth()
|
||||
await auth.deleteUser(userId)
|
||||
console.debug(`Deleted user ${userId} from Firebase Auth and Supabase`)
|
||||
debug(`Deleted user ${userId} from Firebase Auth and Supabase`)
|
||||
} catch (e) {
|
||||
console.error('Error deleting user from Firebase Auth:', e)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {broadcastPrivateMessages} from 'api/helpers/private-messages'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {APIErrors, APIHandler} from './helpers/endpoint'
|
||||
|
||||
// const DELETED_MESSAGE_CONTENT: JSONContent = {
|
||||
// type: 'doc',
|
||||
@@ -31,7 +31,7 @@ export const deleteMessage: APIHandler<'delete-message'> = async ({messageId}, a
|
||||
)
|
||||
|
||||
if (!message) {
|
||||
throw new APIError(404, 'Message not found')
|
||||
throw APIErrors.notFound('Message not found')
|
||||
}
|
||||
|
||||
// Soft delete the message
|
||||
|
||||
@@ -2,7 +2,7 @@ import {broadcastPrivateMessages} from 'api/helpers/private-messages'
|
||||
import {encryptMessage} from 'shared/encryption'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {APIErrors, APIHandler} from './helpers/endpoint'
|
||||
|
||||
export const editMessage: APIHandler<'edit-message'> = async ({messageId, content}, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
@@ -19,7 +19,7 @@ export const editMessage: APIHandler<'edit-message'> = async ({messageId, conten
|
||||
)
|
||||
|
||||
if (!message) {
|
||||
throw new APIError(404, 'Message not found or cannot be edited')
|
||||
throw APIErrors.notFound('Message not found or cannot be edited')
|
||||
}
|
||||
|
||||
const plaintext = JSON.stringify(content)
|
||||
|
||||
86
backend/api/src/get-channel-memberships.ts
Normal file
86
backend/api/src/get-channel-memberships.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import {APIHandler} from 'api/helpers/endpoint'
|
||||
import {PrivateMessageChannel} from 'common/supabase/private-messages'
|
||||
import {groupBy, mapValues} from 'lodash'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
export const getChannelMemberships: APIHandler<'get-channel-memberships'> = async (props, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
const {channelId, lastUpdatedTime, createdTime, limit} = props
|
||||
|
||||
let channels: PrivateMessageChannel[]
|
||||
const convertRow = (r: any) => ({
|
||||
channel_id: r.channel_id as number,
|
||||
notify_after_time: r.notify_after_time as string,
|
||||
created_time: r.created_time as string,
|
||||
last_updated_time: r.last_updated_time as string,
|
||||
})
|
||||
|
||||
if (channelId) {
|
||||
channels = await pg.map(
|
||||
`select channel_id, notify_after_time, pumcm.created_time, last_updated_time
|
||||
from private_user_message_channel_members pumcm
|
||||
join private_user_message_channels pumc on pumc.id = pumcm.channel_id
|
||||
where user_id = $1
|
||||
and channel_id = $2
|
||||
limit $3
|
||||
`,
|
||||
[auth.uid, channelId, limit],
|
||||
convertRow,
|
||||
)
|
||||
} else {
|
||||
channels = await pg.map(
|
||||
`with latest_channels as (select distinct on (pumc.id) pumc.id as channel_id,
|
||||
notify_after_time,
|
||||
pumc.created_time,
|
||||
(select created_time
|
||||
from private_user_messages
|
||||
where channel_id = pumc.id
|
||||
and visibility != 'system_status'
|
||||
and user_id != $1
|
||||
order by created_time desc
|
||||
limit 1) as last_updated_time, -- last_updated_time is the last possible unseen message time
|
||||
pumc.last_updated_time as last_updated_channel_time -- last_updated_channel_time is the last time the channel was updated
|
||||
from private_user_message_channels pumc
|
||||
join private_user_message_channel_members pumcm on pumcm.channel_id = pumc.id
|
||||
inner join private_user_messages pum on pumc.id = pum.channel_id
|
||||
and (pum.visibility != 'introduction' or pum.user_id != $1)
|
||||
where pumcm.user_id = $1
|
||||
and not status = 'left'
|
||||
and ($2 is null or pumcm.created_time > $2)
|
||||
and ($4 is null or pumc.last_updated_time > $4)
|
||||
order by pumc.id, pumc.last_updated_time desc)
|
||||
select *
|
||||
from latest_channels
|
||||
order by last_updated_channel_time desc
|
||||
limit $3
|
||||
`,
|
||||
[auth.uid, createdTime ?? null, limit, lastUpdatedTime ?? null],
|
||||
convertRow,
|
||||
)
|
||||
}
|
||||
if (!channels || channels.length === 0) return {channels: [], memberIdsByChannelId: {}}
|
||||
const channelIds = channels.map((c) => c.channel_id)
|
||||
|
||||
const members = await pg.map(
|
||||
`select channel_id, user_id
|
||||
from private_user_message_channel_members
|
||||
where not user_id = $1
|
||||
and channel_id in ($2:list)
|
||||
and not status = 'left'
|
||||
`,
|
||||
[auth.uid, channelIds],
|
||||
(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),
|
||||
)
|
||||
|
||||
return {
|
||||
channels,
|
||||
memberIdsByChannelId,
|
||||
}
|
||||
}
|
||||
31
backend/api/src/get-channel-seen-time.ts
Normal file
31
backend/api/src/get-channel-seen-time.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import {APIHandler} from 'api/helpers/endpoint'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
export const getLastSeenChannelTime: APIHandler<'get-channel-seen-time'> = async (props, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
const {channelIds} = props
|
||||
const unseens = await pg.map(
|
||||
`select distinct on (channel_id) channel_id, created_time
|
||||
from private_user_seen_message_channels
|
||||
where channel_id = any ($1)
|
||||
and user_id = $2
|
||||
order by channel_id, created_time desc
|
||||
`,
|
||||
[channelIds, auth.uid],
|
||||
(r) => [r.channel_id, r.created_time] as [number, Date],
|
||||
)
|
||||
// When this hits the network, JSON.stringify() turns the Date into an ISO string.
|
||||
// Then the zod schema in the endpoint definition casts it back to front-end Date
|
||||
return unseens
|
||||
}
|
||||
|
||||
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],
|
||||
)
|
||||
}
|
||||
@@ -1,16 +1,7 @@
|
||||
import {type APIHandler} from 'api/helpers/endpoint'
|
||||
import {Row} from 'common/supabase/utils'
|
||||
import {QuestionWithStats} from 'common/api/types'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
export function shuffle<T>(array: T[]): T[] {
|
||||
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]]
|
||||
}
|
||||
return arr
|
||||
}
|
||||
|
||||
export const getCompatibilityQuestions: APIHandler<'get-compatibility-questions'> = async (
|
||||
props,
|
||||
_auth,
|
||||
@@ -40,9 +31,7 @@ export const getCompatibilityQuestions: APIHandler<'get-compatibility-questions'
|
||||
params.push(`%${keyword}%`)
|
||||
}
|
||||
|
||||
const questions = await pg.manyOrNone<
|
||||
Row<'compatibility_prompts'> & {answer_count: number; score: number}
|
||||
>(
|
||||
const questions = await pg.manyOrNone<QuestionWithStats>(
|
||||
`
|
||||
SELECT cp.id,
|
||||
cp.answer_type,
|
||||
@@ -50,25 +39,23 @@ export const getCompatibilityQuestions: APIHandler<'get-compatibility-questions'
|
||||
cp.created_time,
|
||||
cp.creator_id,
|
||||
cp.category,
|
||||
|
||||
-- locale-aware fields
|
||||
COALESCE(cpt.question, cp.question) AS question,
|
||||
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
|
||||
cp.answer_count,
|
||||
CASE
|
||||
WHEN cp.answer_count IS NULL OR cp.answer_count = 0 THEN 0
|
||||
--- community_importance_score is a weighted sum: max val is 2 * answer_count if everyone marks at the highest level of importance
|
||||
--- So we divide by 2 * answer_count to ensure it's between and 0 and 1
|
||||
--- We damp by 20 to ensure questions with few responders don't get a high score
|
||||
--- The square root is to spread the percent of all questions, since in the early days they don't get higher than 50%.
|
||||
--- It does not impact ranking though.
|
||||
--- TODO: remove the square root when we get more answers
|
||||
ELSE SQRT(cp.community_importance_score::float / (cp.answer_count + 20) / 2) * 100
|
||||
END AS community_importance_percent,
|
||||
0 AS score --- update later if needed
|
||||
|
||||
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
|
||||
@@ -86,14 +73,7 @@ export const getCompatibilityQuestions: APIHandler<'get-compatibility-questions'
|
||||
params,
|
||||
)
|
||||
|
||||
// console.debug({questions})
|
||||
|
||||
// const questions = shuffle(dbQuestions)
|
||||
|
||||
// console.debug(
|
||||
// 'got questions',
|
||||
// questions.map((q) => q.question + ' ' + q.score)
|
||||
// )
|
||||
// console.debug(questions.find((q) => q.id === 275))
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
|
||||
47
backend/api/src/get-connection-interests.ts
Normal file
47
backend/api/src/get-connection-interests.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import {APIHandler} from 'api/helpers/endpoint'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
export const getConnectionInterestsEndpoint: APIHandler<'get-connection-interests'> = async (
|
||||
props,
|
||||
auth,
|
||||
) => {
|
||||
return getConnectionInterests(props, auth.uid)
|
||||
}
|
||||
|
||||
export const getConnectionInterests = async (props: any, userId: string) => {
|
||||
const {targetUserId} = props
|
||||
|
||||
if (!targetUserId) {
|
||||
throw new Error('Missing target user ID')
|
||||
}
|
||||
|
||||
if (targetUserId === userId) {
|
||||
throw new Error('Cannot get connection interests for yourself')
|
||||
}
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
// Get what connection interest I have with them
|
||||
const _interests = await pg.query(
|
||||
'SELECT connection_type FROM connection_interests WHERE user_id = $1 AND target_user_id = $2',
|
||||
[userId, targetUserId],
|
||||
)
|
||||
const interests = _interests.map((i: {connection_type: string}) => i.connection_type) ?? []
|
||||
// debug({_interests, interests})
|
||||
|
||||
// Get what connection interest they have with me (filtering out the ones I haven't expressed interest in
|
||||
// so it's risk-free to express interest in them)
|
||||
const _targetInterests = await pg.query(
|
||||
'SELECT connection_type FROM connection_interests WHERE user_id = $1 AND target_user_id = $2',
|
||||
[targetUserId, userId],
|
||||
)
|
||||
const targetInterests =
|
||||
_targetInterests
|
||||
?.map((i: {connection_type: string}) => i.connection_type)
|
||||
?.filter((i: string) => interests.includes(i)) ?? []
|
||||
|
||||
return {
|
||||
interests,
|
||||
targetInterests,
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import {PrivateUser} from 'common/user'
|
||||
import {tryCatch} from 'common/util/try-catch'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {APIErrors, APIHandler} from './helpers/endpoint'
|
||||
|
||||
export const getCurrentPrivateUser: APIHandler<'me/private'> = async (_, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
@@ -13,11 +13,11 @@ export const getCurrentPrivateUser: APIHandler<'me/private'> = async (_, auth) =
|
||||
)
|
||||
|
||||
if (error) {
|
||||
throw new APIError(500, 'Error fetching private user data: ' + error.message)
|
||||
throw APIErrors.internalServerError('Error fetching private user data: ' + error.message)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
throw new APIError(401, 'Your account was not found')
|
||||
throw APIErrors.unauthorized('Your account was not found')
|
||||
}
|
||||
|
||||
return data.data as PrivateUser
|
||||
|
||||
@@ -52,7 +52,7 @@ export const getEvents: APIHandler<'get-events'> = async () => {
|
||||
username: string
|
||||
avatar_url: string | null
|
||||
}>(
|
||||
`SELECT id, name, username, data ->> 'avatarUrl' as avatar_url
|
||||
`SELECT id, name, username, avatar_url
|
||||
FROM users
|
||||
WHERE id = ANY ($1)`,
|
||||
[creatorIds],
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import {APIHandler} from 'api/helpers/endpoint'
|
||||
import {HiddenProfile} from 'common/api/user-types'
|
||||
import {convertPartialUser} from 'common/supabase/users'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
export const getHiddenProfiles: APIHandler<'get-hidden-profiles'> = async (
|
||||
@@ -18,21 +20,15 @@ export const getHiddenProfiles: APIHandler<'get-hidden-profiles'> = async (
|
||||
|
||||
// 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"
|
||||
`select u.id, u.name, u.username, u.avatar_url, 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,
|
||||
}),
|
||||
convertPartialUser,
|
||||
)
|
||||
|
||||
return {status: 'success', hidden: rows, count}
|
||||
return {status: 'success', hidden: rows as HiddenProfile[], count}
|
||||
}
|
||||
|
||||
34
backend/api/src/get-last-messages.ts
Normal file
34
backend/api/src/get-last-messages.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import {ChatMessage} from 'common/chat-message'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {convertPrivateChatMessage} from 'shared/supabase/messages'
|
||||
|
||||
import {APIHandler} from './helpers/endpoint'
|
||||
|
||||
export const getLastMessages: APIHandler<'get-last-messages'> = async (props, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
const {channelIds} = props
|
||||
|
||||
const messages = await pg.map(
|
||||
`select distinct on (channel_id) channel_id, id, user_id, created_time, visibility, ciphertext, iv, tag
|
||||
from private_user_messages
|
||||
where visibility != 'system_status'
|
||||
and channel_id in (
|
||||
select channel_id from private_user_message_channel_members
|
||||
where user_id = $1 and not status = 'left'
|
||||
)
|
||||
${channelIds ? 'and channel_id = any ($2)' : ''}
|
||||
order by channel_id, created_time desc
|
||||
`,
|
||||
[auth.uid, channelIds],
|
||||
convertPrivateChatMessage,
|
||||
)
|
||||
|
||||
// Required to parse to number? If so, should prob create a helper to reuse in other places?
|
||||
return messages.reduce(
|
||||
(acc, msg) => {
|
||||
acc[Number(msg.channelId)] = msg
|
||||
return acc
|
||||
},
|
||||
{} as Record<number, ChatMessage>,
|
||||
)
|
||||
}
|
||||
@@ -25,7 +25,7 @@ export const getLikesAndShipsMain = async (userId: string) => {
|
||||
where creator_id = $1
|
||||
and looking_for_matches
|
||||
and profiles.pinned_url is not null
|
||||
and (data->>'isBannedFromPosting' != 'true' or data->>'isBannedFromPosting' is null)
|
||||
and not is_banned_from_posting
|
||||
order by created_time desc
|
||||
`,
|
||||
[userId],
|
||||
@@ -47,7 +47,7 @@ export const getLikesAndShipsMain = async (userId: string) => {
|
||||
where target_id = $1
|
||||
and looking_for_matches
|
||||
and profiles.pinned_url is not null
|
||||
and (data->>'isBannedFromPosting' != 'true' or data->>'isBannedFromPosting' is null)
|
||||
and not is_banned_from_posting
|
||||
order by created_time desc
|
||||
`,
|
||||
[userId],
|
||||
@@ -74,7 +74,7 @@ export const getLikesAndShipsMain = async (userId: string) => {
|
||||
where target2_id = $1
|
||||
and profiles.looking_for_matches
|
||||
and profiles.pinned_url is not null
|
||||
and (users.data->>'isBannedFromPosting' != 'true' or users.data->>'isBannedFromPosting' is null)
|
||||
and not is_banned_from_posting
|
||||
|
||||
union all
|
||||
|
||||
@@ -87,7 +87,7 @@ export const getLikesAndShipsMain = async (userId: string) => {
|
||||
where target1_id = $1
|
||||
and profiles.looking_for_matches
|
||||
and profiles.pinned_url is not null
|
||||
and (users.data->>'isBannedFromPosting' != 'true' or users.data->>'isBannedFromPosting' is null)
|
||||
and not is_banned_from_posting
|
||||
`,
|
||||
[userId],
|
||||
(r) => ({
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import {debug} from 'common/logger'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
import {APIHandler} from './helpers/endpoint'
|
||||
|
||||
export const getMessagesCount: APIHandler<'get-messages-count'> = async (_, _auth) => {
|
||||
export async function getMessagesCount() {
|
||||
const pg = createSupabaseDirectClient()
|
||||
const result = await pg.one(
|
||||
`
|
||||
@@ -12,8 +13,12 @@ export const getMessagesCount: APIHandler<'get-messages-count'> = async (_, _aut
|
||||
[],
|
||||
)
|
||||
const count = Number(result.count)
|
||||
console.debug('private_user_messages count:', count)
|
||||
debug('private_user_messages count:', count)
|
||||
return {
|
||||
count: count,
|
||||
}
|
||||
}
|
||||
|
||||
export const getMessagesCountEndpoint: APIHandler<'get-messages-count'> = async (_, _auth) => {
|
||||
return await getMessagesCount()
|
||||
}
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import {APIHandler} from 'api/helpers/endpoint'
|
||||
import {defaultLocale} from 'common/constants'
|
||||
import {Notification} from 'common/notifications'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
export const getNotifications: APIHandler<'get-notifications'> = async (props, auth) => {
|
||||
const {limit, after} = props
|
||||
// Helper function to substitute placeholders in template text
|
||||
function substitutePlaceholders(templateText: string, templateData: any): string {
|
||||
let result = templateText
|
||||
if (templateData) {
|
||||
for (const [key, value] of Object.entries(templateData)) {
|
||||
// Replace all occurrences of {key} with the value
|
||||
result = result.replace(new RegExp(`\\{${key}\\}`, 'g'), String(value))
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export const getNotifications: APIHandler<'get-notifications'> = async (props, auth, _req) => {
|
||||
const {limit, after, locale = defaultLocale} = props
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const query = `
|
||||
select case
|
||||
when un.template_id is not null then
|
||||
@@ -12,38 +26,65 @@ export const getNotifications: APIHandler<'get-notifications'> = async (props, a
|
||||
'id', un.notification_id,
|
||||
'userId', un.user_id,
|
||||
'templateId', un.template_id,
|
||||
'title', nt.title,
|
||||
'title', COALESCE(ntt.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,
|
||||
'sourceText', COALESCE(ntt.source_text, nt.source_text),
|
||||
'sourceSlug', nt.source_slug,
|
||||
'sourceUserAvatarUrl', nt.source_user_avatar_url,
|
||||
'data', nt.data
|
||||
'data', nt.data,
|
||||
'templateData', un.data->'templateData'
|
||||
)
|
||||
else
|
||||
un.data
|
||||
end as notification_data
|
||||
from user_notifications un
|
||||
left join notification_templates nt on un.template_id = nt.id
|
||||
left join notification_template_translations ntt
|
||||
on nt.id = ntt.template_id
|
||||
and ntt.locale = $4 -- User's locale
|
||||
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(
|
||||
|
||||
const rawNotifications = await pg.map(
|
||||
query,
|
||||
[auth.uid, limit, after],
|
||||
(row) => row.notification_data as Notification,
|
||||
[auth.uid, limit, after, locale],
|
||||
(row) => row.notification_data,
|
||||
)
|
||||
|
||||
// Process notifications to apply template data substitution
|
||||
const processedNotifications: Notification[] = rawNotifications.map((notif: any) => {
|
||||
if (notif.templateId) {
|
||||
// Apply template data substitution to title and sourceText
|
||||
const templateData = notif.templateData || {}
|
||||
const processedNotif = {...notif}
|
||||
|
||||
if (processedNotif.title) {
|
||||
processedNotif.title = substitutePlaceholders(processedNotif.title, templateData)
|
||||
}
|
||||
|
||||
if (processedNotif.sourceText) {
|
||||
processedNotif.sourceText = substitutePlaceholders(processedNotif.sourceText, templateData)
|
||||
}
|
||||
|
||||
return processedNotif as Notification
|
||||
}
|
||||
return notif as Notification
|
||||
})
|
||||
|
||||
return processedNotifications
|
||||
}
|
||||
|
||||
@@ -1,24 +1,44 @@
|
||||
import {APIError, APIHandler} from 'api/helpers/endpoint'
|
||||
import {OPTION_TABLES} from 'common/profiles/constants'
|
||||
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
|
||||
import {OptionTableKey} from 'common/profiles/constants'
|
||||
import {validateTable} from 'common/profiles/options'
|
||||
import {tryCatch} from 'common/util/try-catch'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {log} from 'shared/utils'
|
||||
|
||||
export const getOptions: APIHandler<'get-options'> = async ({table}, _auth) => {
|
||||
if (!OPTION_TABLES.includes(table)) throw new APIError(400, 'Invalid table')
|
||||
export async function getOptions(table: OptionTableKey, locale?: string): Promise<string[]> {
|
||||
validateTable(table)
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const result = await tryCatch(
|
||||
pg.manyOrNone<{name: string}>(`SELECT interests.name
|
||||
FROM interests`),
|
||||
)
|
||||
let query: string
|
||||
const params: any[] = []
|
||||
|
||||
if (locale) {
|
||||
// Get translated options for the specified locale
|
||||
const translationTable = `${table}_translations`
|
||||
query = `
|
||||
SELECT COALESCE(t.name, o.name) as name
|
||||
FROM ${table} o
|
||||
LEFT JOIN ${translationTable} t ON o.id = t.option_id AND t.locale = $1
|
||||
ORDER BY o.id
|
||||
`
|
||||
params.push(locale)
|
||||
} else {
|
||||
// Get default options (fallback to English)
|
||||
query = `SELECT name FROM ${table} ORDER BY id`
|
||||
}
|
||||
|
||||
const result = await tryCatch(pg.manyOrNone<{name: string}>(query, params))
|
||||
|
||||
if (result.error) {
|
||||
log('Error getting profile options', result.error)
|
||||
throw new APIError(500, 'Error getting profile options')
|
||||
throw APIErrors.internalServerError('Error getting profile options')
|
||||
}
|
||||
|
||||
const names = result.data.map((row) => row.name)
|
||||
return result.data.map((row) => row.name)
|
||||
}
|
||||
|
||||
export const getOptionsEndpoint: APIHandler<'get-options'> = async ({table, locale}, _auth) => {
|
||||
const names = await getOptions(table, locale)
|
||||
return {names}
|
||||
}
|
||||
|
||||
22
backend/api/src/get-pinned-compatibility-questions.ts
Normal file
22
backend/api/src/get-pinned-compatibility-questions.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type {APIHandler} from 'api/helpers/endpoint'
|
||||
import {Row} from 'common/supabase/utils'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
export async function getPinnedQuestionIds(userId: string) {
|
||||
const pg = createSupabaseDirectClient()
|
||||
const rows = await pg.manyOrNone<Row<'compatibility_prompts_pinned'>>(
|
||||
`select * from compatibility_prompts_pinned
|
||||
where user_id = $1
|
||||
order by created_time desc`,
|
||||
[userId],
|
||||
)
|
||||
// newest-first in table; return in that order
|
||||
return rows.map((r) => r.question_id)
|
||||
}
|
||||
|
||||
export const getPinnedCompatibilityQuestions: APIHandler<
|
||||
'get-pinned-compatibility-questions'
|
||||
> = async (_props, auth) => {
|
||||
const pinnedQuestionIds = await getPinnedQuestionIds(auth.uid)
|
||||
return {status: 'success', pinnedQuestionIds}
|
||||
}
|
||||
@@ -1,92 +1,8 @@
|
||||
import {PrivateMessageChannel} from 'common/supabase/private-messages'
|
||||
import {tryCatch} from 'common/util/try-catch'
|
||||
import {groupBy, mapValues} from 'lodash'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {convertPrivateChatMessage} from 'shared/supabase/messages'
|
||||
|
||||
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
|
||||
|
||||
let channels: PrivateMessageChannel[]
|
||||
const convertRow = (r: any) => ({
|
||||
channel_id: r.channel_id as number,
|
||||
notify_after_time: r.notify_after_time as string,
|
||||
created_time: r.created_time as string,
|
||||
last_updated_time: r.last_updated_time as string,
|
||||
})
|
||||
|
||||
if (channelId) {
|
||||
channels = await pg.map(
|
||||
`select channel_id, notify_after_time, pumcm.created_time, last_updated_time
|
||||
from private_user_message_channel_members pumcm
|
||||
join private_user_message_channels pumc on pumc.id = pumcm.channel_id
|
||||
where user_id = $1
|
||||
and channel_id = $2
|
||||
limit $3
|
||||
`,
|
||||
[auth.uid, channelId, limit],
|
||||
convertRow,
|
||||
)
|
||||
} else {
|
||||
channels = await pg.map(
|
||||
`with latest_channels as (select distinct on (pumc.id) pumc.id as channel_id,
|
||||
notify_after_time,
|
||||
pumc.created_time,
|
||||
(select created_time
|
||||
from private_user_messages
|
||||
where channel_id = pumc.id
|
||||
and visibility != 'system_status'
|
||||
and user_id != $1
|
||||
order by created_time desc
|
||||
limit 1) as last_updated_time, -- last_updated_time is the last possible unseen message time
|
||||
pumc.last_updated_time as last_updated_channel_time -- last_updated_channel_time is the last time the channel was updated
|
||||
from private_user_message_channels pumc
|
||||
join private_user_message_channel_members pumcm on pumcm.channel_id = pumc.id
|
||||
inner join private_user_messages pum on pumc.id = pum.channel_id
|
||||
and (pum.visibility != 'introduction' or pum.user_id != $1)
|
||||
where pumcm.user_id = $1
|
||||
and not status = 'left'
|
||||
and ($2 is null or pumcm.created_time > $2)
|
||||
and ($4 is null or pumc.last_updated_time > $4)
|
||||
order by pumc.id, pumc.last_updated_time desc)
|
||||
select *
|
||||
from latest_channels
|
||||
order by last_updated_channel_time desc
|
||||
limit $3
|
||||
`,
|
||||
[auth.uid, createdTime ?? null, limit, lastUpdatedTime ?? null],
|
||||
convertRow,
|
||||
)
|
||||
}
|
||||
if (!channels || channels.length === 0) return {channels: [], memberIdsByChannelId: {}}
|
||||
const channelIds = channels.map((c) => c.channel_id)
|
||||
|
||||
const members = await pg.map(
|
||||
`select channel_id, user_id
|
||||
from private_user_message_channel_members
|
||||
where not user_id = $1
|
||||
and channel_id in ($2:list)
|
||||
and not status = 'left'
|
||||
`,
|
||||
[auth.uid, channelIds],
|
||||
(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),
|
||||
)
|
||||
|
||||
return {
|
||||
channels,
|
||||
memberIdsByChannelId,
|
||||
}
|
||||
}
|
||||
import {APIErrors, APIHandler} from './helpers/endpoint'
|
||||
|
||||
export const getChannelMessagesEndpoint: APIHandler<'get-channel-messages'> = async (
|
||||
props,
|
||||
@@ -100,10 +16,10 @@ export async function getChannelMessages(props: {
|
||||
channelId: number
|
||||
limit?: number
|
||||
id?: number | undefined
|
||||
beforeId?: number | undefined
|
||||
userId: string
|
||||
}) {
|
||||
// console.log('initial message request', props)
|
||||
const {channelId, limit, id, userId} = props
|
||||
const {channelId, limit, id, beforeId, userId} = props
|
||||
const pg = createSupabaseDirectClient()
|
||||
const {data, error} = await tryCatch(
|
||||
pg.map(
|
||||
@@ -115,45 +31,28 @@ export async function getChannelMessages(props: {
|
||||
where pumcm.user_id = $2
|
||||
and pumcm.channel_id = $1)
|
||||
and ($4 is null or id > $4)
|
||||
and ($5 is null or id < $5)
|
||||
and not visibility = 'system_status'
|
||||
order by created_time desc
|
||||
${limit ? 'limit $3' : ''}
|
||||
`,
|
||||
[channelId, userId, limit, id],
|
||||
[channelId, userId, limit, id, beforeId],
|
||||
convertPrivateChatMessage,
|
||||
),
|
||||
)
|
||||
if (error) {
|
||||
console.error(error)
|
||||
throw new APIError(401, 'Error getting messages')
|
||||
console.error('Error getting messages:', error)
|
||||
// If it's a connection pool error, provide more specific error message
|
||||
if (error.message && error.message.includes('MaxClientsInSessionMode')) {
|
||||
throw APIErrors.serviceUnavailable(
|
||||
'Service temporarily unavailable due to high demand. Please try again in a moment.',
|
||||
)
|
||||
}
|
||||
throw APIErrors.internalServerError('Error getting messages', {
|
||||
field: 'database',
|
||||
context: error.message || 'Unknown database error',
|
||||
})
|
||||
}
|
||||
// console.log('final messages', data)
|
||||
return data
|
||||
}
|
||||
|
||||
export const getLastSeenChannelTime: APIHandler<'get-channel-seen-time'> = async (props, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
const {channelIds} = props
|
||||
const unseens = await pg.map(
|
||||
`select distinct on (channel_id) channel_id, created_time
|
||||
from private_user_seen_message_channels
|
||||
where channel_id = any ($1)
|
||||
and user_id = $2
|
||||
order by channel_id, created_time desc
|
||||
`,
|
||||
[channelIds, auth.uid],
|
||||
(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) => {
|
||||
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],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import * as Sentry from '@sentry/node'
|
||||
import {type APIHandler} from 'api/helpers/endpoint'
|
||||
import {OptionTableKey} from 'common/profiles/constants'
|
||||
import {compact} from 'lodash'
|
||||
import {log} from 'shared/monitoring/log'
|
||||
import {convertRow} from 'shared/profiles/supabase'
|
||||
import {createSupabaseDirectClient, pgp} from 'shared/supabase/init'
|
||||
import {
|
||||
@@ -14,17 +16,37 @@ import {
|
||||
where,
|
||||
} from 'shared/supabase/sql-builder'
|
||||
|
||||
/**
|
||||
* Profile query parameters for filtering and pagination
|
||||
*
|
||||
* Defines the available filters and pagination options for retrieving
|
||||
* user profiles from the database. Supports complex filtering based
|
||||
* on demographics, preferences, and location.
|
||||
*/
|
||||
export type profileQueryType = {
|
||||
/** Maximum number of profiles to return */
|
||||
limit?: number | undefined
|
||||
/** Pagination cursor for retrieving next page of results */
|
||||
after?: string | undefined
|
||||
/** Specific user ID to retrieve (for single profile lookups) */
|
||||
userId?: string | undefined
|
||||
/** Name filter for profile search */
|
||||
name?: string | undefined
|
||||
/** Filter by gender identity */
|
||||
genders?: string[] | undefined
|
||||
/** Filter for profiles with photos */
|
||||
hasPhoto?: boolean | undefined
|
||||
/** Filter by education level */
|
||||
education_levels?: string[] | undefined
|
||||
/** Filter by preferred gender for matches */
|
||||
pref_gender?: string[] | undefined
|
||||
/** Minimum preferred age for matches */
|
||||
pref_age_min?: number | undefined
|
||||
/** Maximum preferred age for matches */
|
||||
pref_age_max?: number | undefined
|
||||
/** Minimum drinks consumed per month */
|
||||
drinks_min?: number | undefined
|
||||
/** Maximum drinks consumed per month */
|
||||
drinks_max?: number | undefined
|
||||
big5_openness_min?: number | undefined
|
||||
big5_openness_max?: number | undefined
|
||||
@@ -48,6 +70,12 @@ export type profileQueryType = {
|
||||
has_kids?: number | undefined
|
||||
is_smoker?: boolean | undefined
|
||||
shortBio?: boolean | undefined
|
||||
psychedelics?: string[] | undefined
|
||||
cannabis?: string[] | undefined
|
||||
psychedelics_intention?: string[] | undefined
|
||||
cannabis_intention?: string[] | undefined
|
||||
psychedelics_pref?: string[] | undefined
|
||||
cannabis_pref?: string[] | undefined
|
||||
geodbCityIds?: string[] | undefined
|
||||
lat?: number | undefined
|
||||
lon?: number | undefined
|
||||
@@ -59,6 +87,7 @@ export type profileQueryType = {
|
||||
skipId?: string | undefined
|
||||
orderBy?: string | undefined
|
||||
lastModificationWithin?: string | undefined
|
||||
last_active?: string | undefined
|
||||
locale?: string | undefined
|
||||
} & {
|
||||
[K in OptionTableKey]?: string[] | undefined
|
||||
@@ -68,13 +97,14 @@ export type profileQueryType = {
|
||||
|
||||
export const loadProfiles = async (props: profileQueryType) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
console.debug('loadProfiles', props)
|
||||
log('get-profiles', props)
|
||||
const {
|
||||
limit: limitParam,
|
||||
after,
|
||||
name,
|
||||
userId,
|
||||
genders,
|
||||
hasPhoto,
|
||||
education_levels,
|
||||
pref_gender,
|
||||
pref_age_min,
|
||||
@@ -106,6 +136,12 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
work,
|
||||
is_smoker,
|
||||
shortBio,
|
||||
psychedelics,
|
||||
cannabis,
|
||||
psychedelics_intention,
|
||||
cannabis_intention,
|
||||
psychedelics_pref,
|
||||
cannabis_pref,
|
||||
geodbCityIds,
|
||||
lat,
|
||||
lon,
|
||||
@@ -118,6 +154,7 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
lastModificationWithin,
|
||||
skipId,
|
||||
locale = 'en',
|
||||
last_active,
|
||||
} = props
|
||||
|
||||
const filterLocation = lat && lon && radius
|
||||
@@ -145,8 +182,9 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
|
||||
const userActivityJoin = 'user_activity on user_activity.user_id = profiles.user_id'
|
||||
|
||||
// Need them in the output for the profile card even we don't filter by them
|
||||
const joinInterests = true // !!interests?.length
|
||||
const joinCauses = !!causes?.length
|
||||
const joinCauses = true // !!causes?.length
|
||||
const joinWork = true // !!work?.length
|
||||
|
||||
// Pre-aggregated interests per profile
|
||||
@@ -171,7 +209,7 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
)
|
||||
|
||||
const joins = [
|
||||
orderByParam === 'last_online_time' && leftJoin(userActivityJoin),
|
||||
(orderByParam === 'last_online_time' || last_active) && leftJoin(userActivityJoin),
|
||||
orderByParam === 'compatibility_score' && compatibleWithUserId && join(compatibilityScoreJoin),
|
||||
joinInterests && leftJoin(interestsJoin),
|
||||
joinCauses && leftJoin(causesJoin),
|
||||
@@ -217,9 +255,8 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
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->>'userDeleted' != 'true' or data->>'userDeleted' is null`),
|
||||
where(`not users.is_banned_from_posting`),
|
||||
// where(`data->>'userDeleted' != 'true' or data->>'userDeleted' is null`),
|
||||
|
||||
...keywords.map((word) =>
|
||||
where(
|
||||
@@ -229,7 +266,9 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
OR ${getOptionClauseKeyword('interests')}
|
||||
OR ${getOptionClauseKeyword('causes')}
|
||||
OR ${getOptionClauseKeyword('work')}
|
||||
`,
|
||||
OR lower(headline) ilike '%' || lower($(word)) || '%'
|
||||
OR EXISTS ( SELECT 1 FROM unnest(keywords) AS kw WHERE kw ILIKE '%' || LOWER($(word)) || '%' )
|
||||
`,
|
||||
{word, locale},
|
||||
),
|
||||
),
|
||||
@@ -361,6 +400,43 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
{is_smoker},
|
||||
),
|
||||
|
||||
psychedelics?.length &&
|
||||
where(
|
||||
(psychedelics.includes('never_not_interested') ? 'psychedelics IS NULL OR ' : '') +
|
||||
`psychedelics = ANY($(psychedelics))`,
|
||||
{psychedelics},
|
||||
),
|
||||
|
||||
cannabis?.length &&
|
||||
where(
|
||||
(cannabis.includes('never_not_interested') ? 'cannabis IS NULL OR ' : '') +
|
||||
`cannabis = ANY($(cannabis))`,
|
||||
{cannabis},
|
||||
),
|
||||
|
||||
psychedelics_intention?.length &&
|
||||
where(
|
||||
`(psychedelics IS NOT NULL AND psychedelics != 'never_not_interested') AND (psychedelics_intention IS NULL OR psychedelics_intention = '{}') OR psychedelics_intention && $(psychedelics_intention)`,
|
||||
{psychedelics_intention},
|
||||
),
|
||||
|
||||
cannabis_intention?.length &&
|
||||
where(
|
||||
`(cannabis IS NOT NULL AND cannabis != 'never_not_interested') AND (cannabis_intention IS NULL OR cannabis_intention = '{}') OR cannabis_intention && $(cannabis_intention)`,
|
||||
{cannabis_intention},
|
||||
),
|
||||
|
||||
psychedelics_pref?.length &&
|
||||
where(
|
||||
`psychedelics_pref IS NULL OR psychedelics_pref = '{}' OR psychedelics_pref && $(psychedelics_pref)`,
|
||||
{psychedelics_pref},
|
||||
),
|
||||
|
||||
cannabis_pref?.length &&
|
||||
where(`cannabis_pref IS NULL OR cannabis_pref = '{}' OR cannabis_pref && $(cannabis_pref)`, {
|
||||
cannabis_pref,
|
||||
}),
|
||||
|
||||
geodbCityIds?.length && where(`geodb_city_id = ANY($(geodbCityIds))`, {geodbCityIds}),
|
||||
|
||||
// miles par degree of lat: earth's radius (3950 miles) * pi / 180 = 69.0
|
||||
@@ -399,17 +475,36 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
!shortBio &&
|
||||
where(
|
||||
`bio_length >= ${100}
|
||||
OR headline IS NOT NULL
|
||||
OR array_length(profile_work.work, 1) > 0
|
||||
OR array_length(profile_interests.interests, 1) > 0
|
||||
OR occupation_title IS NOT NULL
|
||||
`,
|
||||
),
|
||||
|
||||
hasPhoto && where("pinned_url IS NOT NULL AND pinned_url != ''"),
|
||||
|
||||
lastModificationWithin &&
|
||||
where(`last_modification_time >= NOW() - INTERVAL $(lastModificationWithin)`, {
|
||||
lastModificationWithin,
|
||||
}),
|
||||
|
||||
last_active &&
|
||||
where(`user_activity.last_online_time >= NOW() - INTERVAL $(last_active_interval)`, {
|
||||
last_active_interval:
|
||||
last_active === 'now'
|
||||
? '30 minutes'
|
||||
: last_active === 'today'
|
||||
? '1 day'
|
||||
: last_active === '3days'
|
||||
? '3 days'
|
||||
: last_active === 'week'
|
||||
? '7 days'
|
||||
: last_active === 'month'
|
||||
? '30 days'
|
||||
: '90 days',
|
||||
}),
|
||||
|
||||
// Exclude profiles that the requester has chosen to hide
|
||||
userId &&
|
||||
where(
|
||||
@@ -425,7 +520,7 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
let selectCols = 'profiles.*, users.name, users.username, users.data as user'
|
||||
if (orderByParam === 'compatibility_score') {
|
||||
selectCols += ', cs.score as compatibility_score'
|
||||
} else if (orderByParam === 'last_online_time') {
|
||||
} else if (orderByParam === 'last_online_time' || last_active) {
|
||||
selectCols += ', user_activity.last_online_time'
|
||||
}
|
||||
if (joinInterests) selectCols += `, COALESCE(profile_interests.interests, '{}') AS interests`
|
||||
@@ -461,6 +556,7 @@ export const getProfiles: APIHandler<'get-profiles'> = async (props, auth) => {
|
||||
return {status: 'success', profiles: profiles, count: count}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
Sentry.captureException(error, {extra: props})
|
||||
return {status: 'fail', profiles: [], count: 0}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import {ENV_CONFIG} from 'common/envs/constants'
|
||||
import {sign} from 'jsonwebtoken'
|
||||
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {APIErrors, 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.")
|
||||
throw APIErrors.internalServerError("No SUPABASE_JWT_SECRET; couldn't sign token.")
|
||||
}
|
||||
const instanceId = ENV_CONFIG.supabaseInstanceId
|
||||
if (!instanceId) {
|
||||
throw new APIError(500, 'No Supabase instance ID in config.')
|
||||
throw APIErrors.internalServerError('No Supabase instance ID in config.')
|
||||
}
|
||||
const payload = {role: 'anon'} // postgres role
|
||||
return {
|
||||
|
||||
68
backend/api/src/get-user-and-profile.ts
Normal file
68
backend/api/src/get-user-and-profile.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import {debug} from 'common/logger'
|
||||
import {ProfileRow} from 'common/profiles/profile'
|
||||
import {convertUser} from 'common/supabase/users'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
import {type APIHandler} from './helpers/endpoint'
|
||||
|
||||
export async function getUserAndProfile(username: string) {
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const user = await pg.oneOrNone('SELECT * FROM users WHERE username ILIKE $1', [username], (r) =>
|
||||
r ? convertUser(r) : null,
|
||||
)
|
||||
if (!user) return null
|
||||
|
||||
// Fetch profile like getProfileRow does
|
||||
const profileRes = await pg.oneOrNone<ProfileRow>('SELECT * FROM profiles WHERE user_id = $1', [
|
||||
user.id,
|
||||
])
|
||||
|
||||
if (!profileRes) return {user, profile: null}
|
||||
|
||||
// Parallel instead of sequential (like getProfileRow does in frontend)
|
||||
const [interestsRes, causesRes, workRes] = await Promise.all([
|
||||
pg.any(
|
||||
`SELECT interests.id
|
||||
FROM profile_interests
|
||||
JOIN interests ON profile_interests.option_id = interests.id
|
||||
WHERE profile_interests.profile_id = $1`,
|
||||
[profileRes.id],
|
||||
),
|
||||
pg.any(
|
||||
`SELECT causes.id
|
||||
FROM profile_causes
|
||||
JOIN causes ON profile_causes.option_id = causes.id
|
||||
WHERE profile_causes.profile_id = $1`,
|
||||
[profileRes.id],
|
||||
),
|
||||
pg.any(
|
||||
`SELECT work.id
|
||||
FROM profile_work
|
||||
JOIN work ON profile_work.option_id = work.id
|
||||
WHERE profile_work.profile_id = $1`,
|
||||
[profileRes.id],
|
||||
),
|
||||
])
|
||||
|
||||
const profileWithItems = {
|
||||
...profileRes,
|
||||
interests: interestsRes.map((r: any) => String(r.id)),
|
||||
causes: causesRes.map((r: any) => String(r.id)),
|
||||
work: workRes.map((r: any) => String(r.id)),
|
||||
}
|
||||
|
||||
return {user, profile: profileWithItems}
|
||||
}
|
||||
|
||||
export const getUserAndProfileHandler: APIHandler<'get-user-and-profile'> = async (
|
||||
{username},
|
||||
_auth,
|
||||
) => {
|
||||
const result = await getUserAndProfile(username)
|
||||
debug(result)
|
||||
return {
|
||||
user: result?.user,
|
||||
profile: result?.profile,
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import {toUserAPIResponse} from 'common/api/user-types'
|
||||
import {APIError} from 'common/api/utils'
|
||||
import {APIErrors} from 'common/api/utils'
|
||||
import {convertUser} from 'common/supabase/users'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
@@ -11,7 +11,7 @@ export const getUser = async (props: {id: string} | {username: string}) => {
|
||||
['id' in props ? props.id : props.username],
|
||||
(r) => (r ? convertUser(r) : null),
|
||||
)
|
||||
if (!user) throw new APIError(404, 'User not found')
|
||||
if (!user) throw APIErrors.notFound('User not found')
|
||||
|
||||
return toUserAPIResponse(user)
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export const getUser = async (props: {id: string} | {username: string}) => {
|
||||
// where ${'id' in props ? 'id' : 'username'} = $1`,
|
||||
// ['id' in props ? props.id : props.username]
|
||||
// )
|
||||
// if (!liteUser) throw new APIError(404, 'User not found')
|
||||
// if (!liteUser) throw APIErrors.notFound('User not found')
|
||||
//
|
||||
// return removeNullOrUndefinedProps(liteUser)
|
||||
// }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import * as Sentry from '@sentry/node'
|
||||
import {
|
||||
API,
|
||||
APIPath,
|
||||
@@ -5,14 +6,14 @@ import {
|
||||
APISchema,
|
||||
ValidatedAPIParams,
|
||||
} from 'common/api/schema'
|
||||
import {APIError} from 'common/api/utils'
|
||||
import {APIErrors} from 'common/api/utils'
|
||||
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'
|
||||
export {APIErrors} from 'common/api/utils'
|
||||
|
||||
// export type Json = Record<string, unknown> | Json[]
|
||||
// export type JsonHandler<T extends Json> = (
|
||||
@@ -65,32 +66,37 @@ export const parseCredentials = async (req: Request): Promise<Credentials> => {
|
||||
const auth = admin.auth()
|
||||
const authHeader = req.get('Authorization')
|
||||
if (!authHeader) {
|
||||
throw new APIError(401, 'Missing Authorization header.')
|
||||
throw APIErrors.unauthorized('Missing Authorization header.')
|
||||
}
|
||||
const authParts = authHeader.split(' ')
|
||||
if (authParts.length !== 2) {
|
||||
throw new APIError(401, 'Invalid Authorization header.')
|
||||
throw APIErrors.unauthorized('Invalid Authorization header.')
|
||||
}
|
||||
|
||||
const [scheme, payload] = authParts
|
||||
switch (scheme) {
|
||||
case 'Bearer':
|
||||
if (payload === 'undefined') {
|
||||
throw new APIError(401, 'Firebase JWT payload undefined.')
|
||||
throw APIErrors.unauthorized('Firebase JWT payload undefined.')
|
||||
}
|
||||
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 _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.')
|
||||
console.error('Error verifying Firebase JWT: ', err, scheme, payload, {
|
||||
jwtHeader: _header,
|
||||
})
|
||||
Sentry.captureException(err, {
|
||||
extra: {jwtHeader: _header},
|
||||
})
|
||||
throw APIErrors.internalServerError('Error validating token.')
|
||||
}
|
||||
case 'Key':
|
||||
return {kind: 'key', data: payload}
|
||||
default:
|
||||
throw new APIError(401, 'Invalid auth scheme; must be "Key" or "Bearer".')
|
||||
throw APIErrors.unauthorized('Invalid auth scheme; must be "Key" or "Bearer".')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,7 +104,7 @@ export const lookupUser = async (creds: Credentials): Promise<AuthedUser> => {
|
||||
switch (creds.kind) {
|
||||
case 'jwt': {
|
||||
if (typeof creds.data.user_id !== 'string') {
|
||||
throw new APIError(401, 'JWT must contain user ID.')
|
||||
throw APIErrors.unauthorized('JWT must contain user ID.')
|
||||
}
|
||||
return {uid: creds.data.user_id, creds}
|
||||
}
|
||||
@@ -106,12 +112,12 @@ export const lookupUser = async (creds: Credentials): Promise<AuthedUser> => {
|
||||
const key = creds.data
|
||||
const privateUser = await getPrivateUserByKey(key)
|
||||
if (!privateUser) {
|
||||
throw new APIError(401, `No private user exists with API key ${key}.`)
|
||||
throw APIErrors.unauthorized(`No private user exists with API key ${key}.`)
|
||||
}
|
||||
return {uid: privateUser.id, creds: {privateUser, ...creds}}
|
||||
}
|
||||
default:
|
||||
throw new APIError(401, 'Invalid credential type.')
|
||||
throw APIErrors.unauthorized('Invalid credential type.')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,15 +125,20 @@ export const validate = <T extends z.ZodTypeAny>(schema: T, val: unknown) => {
|
||||
const result = schema.safeParse(val)
|
||||
if (!result.success) {
|
||||
const issues = result.error.issues.map((i) => {
|
||||
const field = i.path.join('.')
|
||||
return {
|
||||
field: i.path.join('.') || null,
|
||||
error: i.message,
|
||||
field: field === '' ? undefined : field,
|
||||
context: i.message,
|
||||
}
|
||||
})
|
||||
if (issues.length > 0) {
|
||||
log.error(issues.map((i) => `${i.field}: ${i.error}`).join('\n'))
|
||||
log.error(issues.map((i) => `${i.field}: ${i.context}`).join('\n'))
|
||||
}
|
||||
throw new APIError(400, 'Error validating request.', issues)
|
||||
console.error('Validation failed', {issues, schema, val})
|
||||
Sentry.captureException(APIErrors.validationFailed(issues), {
|
||||
extra: {issues, schema, val},
|
||||
})
|
||||
throw APIErrors.validationFailed(issues)
|
||||
} else {
|
||||
return result.data as z.infer<T>
|
||||
}
|
||||
@@ -227,17 +238,21 @@ function checkRateLimit(name: string, req: Request, res: Response, auth?: Authed
|
||||
|
||||
if (state.count > limit) {
|
||||
res.setHeader('Retry-After', String(reset))
|
||||
throw new APIError(429, 'Too Many Requests: rate limit exceeded.')
|
||||
throw APIErrors.rateLimitExceeded('Too Many Requests: rate limit exceeded.')
|
||||
}
|
||||
}
|
||||
|
||||
export const typedEndpoint = <N extends APIPath>(name: N, handler: APIHandler<N>) => {
|
||||
const apiSchema = API[name] as APISchema<N> & {
|
||||
deprecation?: {deprecated: boolean; migrationPath?: string; sunsetDate?: string}
|
||||
}
|
||||
const {
|
||||
props: propSchema,
|
||||
authed: authRequired,
|
||||
rateLimited = false,
|
||||
method,
|
||||
} = API[name] as APISchema<N>
|
||||
deprecation,
|
||||
} = apiSchema
|
||||
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
let authUser: AuthedUser | undefined = undefined
|
||||
@@ -256,6 +271,10 @@ export const typedEndpoint = <N extends APIPath>(name: N, handler: APIHandler<N>
|
||||
}
|
||||
}
|
||||
|
||||
if (deprecation?.deprecated) {
|
||||
log('Deprecated endpoint called:', name, req)
|
||||
}
|
||||
|
||||
const props = {
|
||||
...(method === 'GET' ? req.query : req.body),
|
||||
...req.params,
|
||||
@@ -275,6 +294,17 @@ export const typedEndpoint = <N extends APIPath>(name: N, handler: APIHandler<N>
|
||||
const result = hasContinue ? resultOptionalContinue.result : resultOptionalContinue
|
||||
|
||||
if (!res.headersSent) {
|
||||
// Add deprecation headers for deprecated endpoints
|
||||
if (deprecation?.deprecated) {
|
||||
res.setHeader('Deprecation', 'true')
|
||||
if (deprecation.sunsetDate) {
|
||||
res.setHeader('Sunset', deprecation.sunsetDate)
|
||||
}
|
||||
if (deprecation.migrationPath) {
|
||||
res.setHeader('Link', `<${deprecation.migrationPath}>; rel="migration"`)
|
||||
}
|
||||
}
|
||||
|
||||
// Convert bigint to number, b/c JSON doesn't support bigint.
|
||||
const convertedResult = deepConvertBigIntToNumber(result)
|
||||
// console.debug('API result', convertedResult)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {type JSONContent} from '@tiptap/core'
|
||||
import {APIError} from 'common/api/utils'
|
||||
import {APIErrors} from 'common/api/utils'
|
||||
import {ChatVisibility} from 'common/chat-message'
|
||||
import {debug} from 'common/logger'
|
||||
import {Json} from 'common/supabase/schema'
|
||||
import {User} from 'common/user'
|
||||
import {parseJsonContentToText} from 'common/util/parse'
|
||||
@@ -8,16 +9,14 @@ import dayjs from 'dayjs'
|
||||
import timezone from 'dayjs/plugin/timezone'
|
||||
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 {first} from 'lodash'
|
||||
import {track} from 'shared/analytics'
|
||||
import {encryptMessage} from 'shared/encryption'
|
||||
import {sendMobileNotifications, sendWebNotifications} from 'shared/mobile'
|
||||
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)
|
||||
@@ -128,7 +127,7 @@ export const createPrivateUserMessageMain = async (
|
||||
and user_id = $2`,
|
||||
[channelId, creator.id],
|
||||
)
|
||||
if (!authorized) throw new APIError(403, 'You are not authorized to post to this channel')
|
||||
if (!authorized) throw APIErrors.forbidden('You are not authorized to post to this channel')
|
||||
|
||||
await insertPrivateMessage(content, channelId, creator.id, visibility, pg)
|
||||
|
||||
@@ -176,7 +175,7 @@ const notifyOtherUserInChannelIfInactive = async (
|
||||
// TODO: notification only for active user
|
||||
|
||||
const receiver = await getUser(receiverId)
|
||||
console.debug('receiver:', receiver)
|
||||
debug('receiver:', receiver)
|
||||
if (!receiver) return
|
||||
|
||||
// Push notifs
|
||||
@@ -186,9 +185,16 @@ const notifyOtherUserInChannelIfInactive = async (
|
||||
body: textContent,
|
||||
url: `/messages/${channelId}`,
|
||||
}
|
||||
await sendWebNotifications(pg, receiverId, JSON.stringify(payload))
|
||||
await sendMobileNotifications(pg, receiverId, payload)
|
||||
|
||||
try {
|
||||
await sendWebNotifications(pg, receiverId, JSON.stringify(payload))
|
||||
} catch (err) {
|
||||
console.error('Failed to send web notification:', err)
|
||||
}
|
||||
try {
|
||||
await sendMobileNotifications(pg, receiverId, payload)
|
||||
} catch (err) {
|
||||
console.error('Failed to send mobile notification:', err)
|
||||
}
|
||||
const startOfDay = dayjs().tz('America/Los_Angeles').startOf('day').toISOString()
|
||||
const previousMessagesThisDayBetweenTheseUsers = await pg.one(
|
||||
`select count(*)
|
||||
@@ -207,156 +213,7 @@ const notifyOtherUserInChannelIfInactive = async (
|
||||
|
||||
const createNewMessageNotification = async (fromUser: User, toUser: User, channelId: number) => {
|
||||
const privateUser = await getPrivateUser(toUser.id)
|
||||
console.debug('privateUser:', privateUser)
|
||||
debug('privateUser:', privateUser)
|
||||
if (!privateUser) return
|
||||
await sendNewMessageEmail(privateUser, fromUser, toUser, channelId)
|
||||
}
|
||||
|
||||
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!,
|
||||
)
|
||||
// Retrieve subscription from the database
|
||||
const subscriptions = await getSubscriptionsFromDB(pg, userId)
|
||||
for (const subscription of subscriptions) {
|
||||
try {
|
||||
console.log('Sending notification to:', subscription.endpoint, payload)
|
||||
await webPush.sendNotification(subscription, payload)
|
||||
} catch (err: any) {
|
||||
console.log('Failed to send notification', err)
|
||||
if (err.statusCode === 410 || err.statusCode === 404) {
|
||||
console.warn('Removing expired subscription', subscription.endpoint)
|
||||
await removeSubscription(pg, subscription.endpoint, userId)
|
||||
} else {
|
||||
console.error('Push failed', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function getSubscriptionsFromDB(pg: SupabaseDirectClient, userId: string) {
|
||||
try {
|
||||
const subscriptions = await pg.manyOrNone(
|
||||
`
|
||||
select endpoint, keys
|
||||
from push_subscriptions
|
||||
where user_id = $1
|
||||
`,
|
||||
[userId],
|
||||
)
|
||||
|
||||
return subscriptions.map((sub) => ({
|
||||
endpoint: sub.endpoint,
|
||||
keys: sub.keys,
|
||||
}))
|
||||
} catch (err) {
|
||||
console.error('Error fetching subscriptions', err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
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],
|
||||
)
|
||||
}
|
||||
|
||||
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],
|
||||
)
|
||||
}
|
||||
|
||||
async function sendMobileNotifications(
|
||||
pg: SupabaseDirectClient,
|
||||
userId: string,
|
||||
payload: PushPayload,
|
||||
) {
|
||||
const subscriptions = await getMobileSubscriptionsFromDB(pg, userId)
|
||||
for (const subscription of subscriptions) {
|
||||
await sendPushToToken(pg, userId, subscription.token, payload)
|
||||
}
|
||||
}
|
||||
|
||||
interface PushPayload {
|
||||
title: string
|
||||
body: string
|
||||
url: string
|
||||
data?: Record<string, string>
|
||||
}
|
||||
|
||||
export async function sendPushToToken(
|
||||
pg: SupabaseDirectClient,
|
||||
userId: string,
|
||||
token: string,
|
||||
payload: PushPayload,
|
||||
) {
|
||||
const message: TokenMessage = {
|
||||
token,
|
||||
android: {
|
||||
notification: {
|
||||
title: payload.title,
|
||||
body: payload.body,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
endpoint: payload.url,
|
||||
},
|
||||
}
|
||||
|
||||
try {
|
||||
// Fine to create at each call, as it's a cached singleton
|
||||
const fcm = admin.messaging()
|
||||
console.log('Sending notification to:', token, message)
|
||||
const response = await fcm.send(message)
|
||||
console.log('Push sent successfully:', response)
|
||||
return response
|
||||
} 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}
|
||||
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'
|
||||
) {
|
||||
console.warn('Removing invalid FCM token')
|
||||
await removeMobileSubscription(pg, token, userId)
|
||||
}
|
||||
} else {
|
||||
console.error('Unknown error:', err)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
export async function getMobileSubscriptionsFromDB(pg: SupabaseDirectClient, userId: string) {
|
||||
try {
|
||||
const subscriptions = await pg.manyOrNone(
|
||||
`
|
||||
select token
|
||||
from push_subscriptions_mobile
|
||||
where user_id = $1
|
||||
`,
|
||||
[userId],
|
||||
)
|
||||
|
||||
return subscriptions
|
||||
} catch (err) {
|
||||
console.error('Error fetching subscriptions', err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {APIError, APIHandler} from 'api/helpers/endpoint'
|
||||
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
|
||||
import {isAdminId} from 'common/envs/constants'
|
||||
import {convertComment} from 'common/supabase/comment'
|
||||
import {Row} from 'common/supabase/utils'
|
||||
@@ -12,11 +12,11 @@ export const hideComment: APIHandler<'hide-comment'> = async ({commentId, hide},
|
||||
[commentId],
|
||||
)
|
||||
if (!comment) {
|
||||
throw new APIError(404, 'Comment not found')
|
||||
throw APIErrors.notFound('Comment not found')
|
||||
}
|
||||
|
||||
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')
|
||||
throw APIErrors.forbidden('You are not allowed to hide this comment')
|
||||
}
|
||||
|
||||
await pg.none(`update profile_comments set hidden = $2 where id = $1`, [commentId, hide])
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {APIErrors, 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')
|
||||
if (auth.uid === hiddenUserId) throw APIErrors.badRequest('You cannot hide yourself')
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {APIError, APIHandler} from 'api/helpers/endpoint'
|
||||
import {APIErrors, 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'
|
||||
@@ -8,14 +8,14 @@ export const leavePrivateUserMessageChannel: APIHandler<
|
||||
> = async ({channelId}, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
const user = await getUser(auth.uid)
|
||||
if (!user) throw new APIError(401, 'Your account was not found')
|
||||
if (!user) throw APIErrors.unauthorized('Your account was not found')
|
||||
|
||||
const membershipStatus = await pg.oneOrNone(
|
||||
`select status from private_user_message_channel_members
|
||||
where channel_id = $1 and user_id = $2`,
|
||||
[channelId, auth.uid],
|
||||
)
|
||||
if (!membershipStatus) throw new APIError(403, 'You are not authorized to post to this channel')
|
||||
if (!membershipStatus) throw APIErrors.forbidden('You are not authorized to post to this channel')
|
||||
log('membershipStatus: ' + membershipStatus)
|
||||
|
||||
// add message that the user left the channel
|
||||
|
||||
@@ -5,7 +5,7 @@ import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {log} from 'shared/utils'
|
||||
|
||||
import {getHasFreeLike} from './has-free-like'
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {APIErrors, APIHandler} from './helpers/endpoint'
|
||||
|
||||
export const likeProfile: APIHandler<'like-profile'> = async (props, auth) => {
|
||||
const {targetUserId, remove} = props
|
||||
@@ -22,7 +22,7 @@ export const likeProfile: APIHandler<'like-profile'> = async (props, auth) => {
|
||||
)
|
||||
|
||||
if (error) {
|
||||
throw new APIError(500, 'Failed to remove like: ' + error.message)
|
||||
throw APIErrors.internalServerError('Failed to remove like: ' + error.message)
|
||||
}
|
||||
return {status: 'success'}
|
||||
}
|
||||
@@ -44,7 +44,7 @@ export const likeProfile: APIHandler<'like-profile'> = async (props, auth) => {
|
||||
|
||||
if (!hasFreeLike) {
|
||||
// Charge for like.
|
||||
throw new APIError(403, 'You already liked someone today!')
|
||||
throw APIErrors.forbidden('You already liked someone today!')
|
||||
}
|
||||
|
||||
// Insert the new like
|
||||
@@ -56,7 +56,7 @@ export const likeProfile: APIHandler<'like-profile'> = async (props, auth) => {
|
||||
)
|
||||
|
||||
if (error) {
|
||||
throw new APIError(500, 'Failed to add like: ' + error.message)
|
||||
throw APIErrors.internalServerError('Failed to add like: ' + error.message)
|
||||
}
|
||||
|
||||
const continuation = async () => {
|
||||
|
||||
412
backend/api/src/llm-extract-profile.ts
Normal file
412
backend/api/src/llm-extract-profile.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
import {JSONContent} from '@tiptap/core'
|
||||
import {getOptions} from 'api/get-options'
|
||||
import {APIErrors, APIHandler} from 'api/helpers/endpoint'
|
||||
import {searchLocation} from 'api/search-location'
|
||||
import {
|
||||
CANNABIS_CHOICES,
|
||||
DIET_CHOICES,
|
||||
EDUCATION_CHOICES,
|
||||
GENDERS,
|
||||
LANGUAGE_CHOICES,
|
||||
MBTI_CHOICES,
|
||||
POLITICAL_CHOICES,
|
||||
PSYCHEDELICS_CHOICES,
|
||||
RACE_CHOICES,
|
||||
RELATIONSHIP_CHOICES,
|
||||
RELATIONSHIP_STATUS_CHOICES,
|
||||
RELIGION_CHOICES,
|
||||
ROMANTIC_CHOICES,
|
||||
SUBSTANCE_INTENTION_CHOICES,
|
||||
SUBSTANCE_PREFERENCE_CHOICES,
|
||||
} from 'common/choices'
|
||||
import {debug} from 'common/logger'
|
||||
import {ProfileWithoutUser} from 'common/profiles/profile'
|
||||
import {SITE_ORDER} from 'common/socials'
|
||||
import {removeNullOrUndefinedProps} from 'common/util/object'
|
||||
import {parseJsonContentToText} from 'common/util/parse'
|
||||
import {createHash} from 'crypto'
|
||||
import {promises as fs} from 'fs'
|
||||
import {tmpdir} from 'os'
|
||||
import {join} from 'path'
|
||||
import {log} from 'shared/monitoring/log'
|
||||
import {convertToJSONContent, extractGoogleDocId} from 'shared/parse'
|
||||
|
||||
const MAX_CONTEXT_LENGTH = 7 * 10 * 30 * 50
|
||||
const USE_CACHE = true
|
||||
const CACHE_DIR = join(tmpdir(), 'compass-llm-cache')
|
||||
const CACHE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours
|
||||
|
||||
function getCacheKey(content: string): string {
|
||||
if (!USE_CACHE) return ''
|
||||
const hash = createHash('sha256')
|
||||
hash.update(content)
|
||||
return hash.digest('hex')
|
||||
}
|
||||
|
||||
async function getCachedResult(cacheKey: string): Promise<Partial<ProfileWithoutUser> | null> {
|
||||
if (!USE_CACHE) return null
|
||||
try {
|
||||
const cacheFile = join(CACHE_DIR, `${cacheKey}.json`)
|
||||
const stats = await fs.stat(cacheFile)
|
||||
|
||||
if (Date.now() - stats.mtime.getTime() > CACHE_TTL_MS) {
|
||||
await fs.unlink(cacheFile)
|
||||
return null
|
||||
}
|
||||
|
||||
const cachedData = await fs.readFile(cacheFile, 'utf-8')
|
||||
return JSON.parse(cachedData)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function setCachedResult(
|
||||
cacheKey: string,
|
||||
result: Partial<ProfileWithoutUser>,
|
||||
): Promise<void> {
|
||||
if (!USE_CACHE) return
|
||||
try {
|
||||
await fs.mkdir(CACHE_DIR, {recursive: true})
|
||||
const cacheFile = join(CACHE_DIR, `${cacheKey}.json`)
|
||||
await fs.writeFile(cacheFile, JSON.stringify(result), 'utf-8')
|
||||
debug('Cached LLM result', {cacheKey: cacheKey.substring(0, 8)})
|
||||
} catch (error) {
|
||||
log('Failed to write cache', {cacheKey, error})
|
||||
// Don't throw - caching failure shouldn't break the main flow
|
||||
}
|
||||
}
|
||||
|
||||
async function callGemini(text: string) {
|
||||
const apiKey = process.env.GEMINI_API_KEY
|
||||
|
||||
if (!apiKey) {
|
||||
log('GEMINI_API_KEY not configured')
|
||||
throw APIErrors.internalServerError('Profile extraction service is not configured')
|
||||
}
|
||||
|
||||
const models = [
|
||||
'gemini-2.5-flash',
|
||||
'gemini-3-flash-preview',
|
||||
'gemini-2.5-flash-lite',
|
||||
'gemini-3.1-flash-preview',
|
||||
]
|
||||
|
||||
for (const model of models) {
|
||||
const response = await fetch(
|
||||
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
contents: [
|
||||
{
|
||||
parts: [
|
||||
{
|
||||
text: text.slice(0, MAX_CONTEXT_LENGTH),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
generationConfig: {
|
||||
temperature: 0,
|
||||
topP: 0.95,
|
||||
topK: 40,
|
||||
responseMimeType: 'application/json',
|
||||
},
|
||||
}),
|
||||
},
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
log(`Gemini API error with ${model}`, {status: response.status, error: errorText})
|
||||
if (model !== models[models.length - 1]) continue
|
||||
throw APIErrors.internalServerError('Failed to extract profile data')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const outputText = data.candidates?.[0]?.content?.parts?.[0]?.text
|
||||
return outputText
|
||||
}
|
||||
}
|
||||
|
||||
async function _callClaude(text: string) {
|
||||
// We don't use it as there is no free tier
|
||||
const apiKey = process.env.ANTHROPIC_API_KEY
|
||||
|
||||
if (!apiKey) {
|
||||
log('ANTHROPIC_API_KEY not configured')
|
||||
throw APIErrors.internalServerError('Profile extraction service is not configured')
|
||||
}
|
||||
|
||||
const response = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': apiKey,
|
||||
'anthropic-version': '2023-06-01',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'claude-sonnet-4-5',
|
||||
max_tokens: 1024,
|
||||
temperature: 0,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: text.slice(0, MAX_CONTEXT_LENGTH),
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
log('Anthropic API error', {status: response.status, error: errorText})
|
||||
throw APIErrors.internalServerError('Failed to extract profile data')
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const outputText = data.content?.[0]?.text
|
||||
return outputText
|
||||
}
|
||||
|
||||
async function callLLM(content: string, locale?: string): Promise<Partial<ProfileWithoutUser>> {
|
||||
const [INTERESTS, CAUSE_AREAS, WORK_AREAS] = await Promise.all([
|
||||
getOptions('interests', locale),
|
||||
getOptions('causes', locale),
|
||||
getOptions('work', locale),
|
||||
])
|
||||
|
||||
const PROFILE_FIELDS: Partial<Record<keyof ProfileWithoutUser, any>> = {
|
||||
// Basic info
|
||||
age: 'Number. Age in years.',
|
||||
gender: `One of: ${Object.values(GENDERS).join(', ')}. Infer if you have enough evidence`,
|
||||
height_in_inches: 'Number. Height converted to inches.',
|
||||
city: 'String. Current city of residence (English spelling).',
|
||||
country: 'String. Current country of residence (English spelling).',
|
||||
city_latitude: 'Number. Latitude of current city.',
|
||||
city_longitude: 'Number. Longitude of current city.',
|
||||
|
||||
// Background
|
||||
raised_in_city: 'String. City where they grew up (English spelling).',
|
||||
raised_in_country: 'String. Country where they grew up (English spelling).',
|
||||
raised_in_lat: 'Number. Latitude of city where they grew up.',
|
||||
raised_in_lon: 'Number. Longitude of city where they grew up.',
|
||||
university: 'String. University or college attended.',
|
||||
education_level: `One of: ${Object.values(EDUCATION_CHOICES).join(', ')}`,
|
||||
company: 'String. Current employer or company name.',
|
||||
occupation_title: 'String. Current job title.',
|
||||
|
||||
// Lifestyle
|
||||
is_smoker: 'Boolean. Whether they smoke.',
|
||||
drinks_per_month: 'Number. Estimated alcoholic drinks per month.',
|
||||
has_kids: 'Number. 0 if no kids, otherwise number of kids.',
|
||||
wants_kids_strength:
|
||||
'Number 0–4. How strongly they want kids (0 = definitely not, 4 = definitely yes).',
|
||||
diet: `Array. Any of: ${Object.values(DIET_CHOICES).join(', ')}`,
|
||||
ethnicity: `Array. Any of: ${Object.values(RACE_CHOICES).join(', ')}`,
|
||||
|
||||
// Substances
|
||||
psychedelics: `One of: ${Object.values(PSYCHEDELICS_CHOICES).join(', ')}. Usage frequency of psychedelics/plant medicine, only if explicitly stated.`,
|
||||
cannabis: `One of: ${Object.values(CANNABIS_CHOICES).join(', ')}. Usage frequency of cannabis, only if explicitly stated.`,
|
||||
psychedelics_intention: `Array. Any of: ${Object.values(SUBSTANCE_INTENTION_CHOICES).join(', ')}. Only if they use psychedelics.`,
|
||||
cannabis_intention: `Array. Any of: ${Object.values(SUBSTANCE_INTENTION_CHOICES).join(', ')}. Only if they use cannabis.`,
|
||||
psychedelics_pref: `Array. Any of: ${Object.values(SUBSTANCE_PREFERENCE_CHOICES).join(', ')}. Partner preference for psychedelics use.`,
|
||||
cannabis_pref: `Array. Any of: ${Object.values(SUBSTANCE_PREFERENCE_CHOICES).join(', ')}. Partner preference for cannabis use.`,
|
||||
|
||||
// Identity — big5 only if person explicitly states a score, never infer from personality description
|
||||
mbti: `One of: ${Object.values(MBTI_CHOICES).join(', ')}`,
|
||||
big5_openness: 'Number 0–100. Only if explicitly self-reported, never infer.',
|
||||
big5_conscientiousness: 'Number 0–100. Only if explicitly self-reported, never infer.',
|
||||
big5_extraversion: 'Number 0–100. Only if explicitly self-reported, never infer.',
|
||||
big5_agreeableness: 'Number 0–100. Only if explicitly self-reported, never infer.',
|
||||
big5_neuroticism: 'Number 0–100. Only if explicitly self-reported, never infer.',
|
||||
|
||||
// Beliefs
|
||||
religion: `Array. Any of: ${Object.values(RELIGION_CHOICES).join(', ')}`,
|
||||
religious_beliefs:
|
||||
'String. Free-form elaboration on religious views, only if explicitly stated.',
|
||||
political_beliefs: `Array. Any of: ${Object.values(POLITICAL_CHOICES).join(', ')}`,
|
||||
political_details:
|
||||
'String. Free-form elaboration on political views, only if explicitly stated.',
|
||||
|
||||
// Preferences
|
||||
pref_age_min: 'Number. Minimum preferred age of match.',
|
||||
pref_age_max: 'Number. Maximum preferred age of match.',
|
||||
pref_gender: `Array. Any of: ${Object.values(GENDERS).join(', ')}`,
|
||||
pref_relation_styles: `Array. Any of: ${Object.values(RELATIONSHIP_CHOICES).join(', ')}`,
|
||||
pref_romantic_styles: `Array. Any of: ${Object.values(ROMANTIC_CHOICES).join(', ')}`,
|
||||
relationship_status: `Array. Any of: ${Object.values(RELATIONSHIP_STATUS_CHOICES).join(', ')}`,
|
||||
|
||||
// Languages
|
||||
languages: `Array. Any of: ${Object.values(LANGUAGE_CHOICES).join(', ')}. If none, infer from text.`,
|
||||
|
||||
// Free-form
|
||||
headline:
|
||||
'String. Summary of who they are, in their own voice (first person). Maximum 200 characters total. Cannot be null.',
|
||||
keywords: 'Array of 3–6 short tags summarising the person.',
|
||||
links: `Object. Key is any of: ${SITE_ORDER.join(', ')}.`,
|
||||
|
||||
// Taxonomies — match existing labels first, only add new if truly no close match exists
|
||||
interests: `Array. Prefer existing labels, only add new if no close match. Any of: ${INTERESTS.join(', ')}`,
|
||||
causes: `Array. Prefer existing labels, only add new if no close match. Any of: ${CAUSE_AREAS.join(', ')}`,
|
||||
work: `Array. Use only existing labels, do not add new if no close match. Any of: ${WORK_AREAS.join(', ')}`,
|
||||
}
|
||||
|
||||
const EXTRACTION_PROMPT = `You are a profile information extraction expert analyzing text from a personal webpage, LinkedIn, bio, or similar source.
|
||||
|
||||
TASK: Extract structured profile data and return it as a single valid JSON object.
|
||||
|
||||
RULES:
|
||||
- Only extract information that is EXPLICITLY stated — do not infer, guess, or hallucinate
|
||||
- Omit the key in the output for missing fields
|
||||
- For taxonomy fields (interests, causes, work): match existing labels first; only add a new label if truly no existing one is close
|
||||
- For big5 scores: only populate if the person explicitly states a test result — never infer from personality description
|
||||
- Return valid JSON only — no markdown, no explanation, no extra text
|
||||
|
||||
SCHEMA (each value describes the expected type and accepted values):
|
||||
${JSON.stringify(PROFILE_FIELDS, null, 2)}
|
||||
|
||||
TEXT TO ANALYZE:
|
||||
`
|
||||
const text = EXTRACTION_PROMPT + content
|
||||
if (text.length > MAX_CONTEXT_LENGTH) {
|
||||
log('Content exceeds maximum length, will be cropped', {length: text.length})
|
||||
// throw APIErrors.badRequest('Content exceeds maximum length')
|
||||
}
|
||||
debug({text})
|
||||
|
||||
const cacheKey = getCacheKey(text)
|
||||
const cached = await getCachedResult(cacheKey)
|
||||
if (cached) {
|
||||
debug('Using cached LLM result', {cacheKey: cacheKey.substring(0, 8)})
|
||||
return cached
|
||||
}
|
||||
|
||||
const outputText = await callGemini(text)
|
||||
// const outputText = JSON.stringify({})
|
||||
|
||||
if (!outputText) {
|
||||
throw APIErrors.internalServerError('Failed to parse LLM response')
|
||||
}
|
||||
|
||||
let parsed: Partial<ProfileWithoutUser>
|
||||
try {
|
||||
parsed = typeof outputText === 'string' ? JSON.parse(outputText) : outputText
|
||||
parsed = removeNullOrUndefinedProps(parsed)
|
||||
} catch (parseError) {
|
||||
log('Failed to parse LLM response as JSON', {outputText, parseError})
|
||||
throw APIErrors.internalServerError('Failed to parse extracted data')
|
||||
}
|
||||
|
||||
if (parsed.city) {
|
||||
if (!parsed.city_latitude || !parsed.city_longitude) {
|
||||
const result = await searchLocation({term: parsed.city, limit: 1})
|
||||
const locations = result.data?.data
|
||||
parsed.city_latitude = locations?.[0]?.latitude
|
||||
parsed.city_longitude = locations?.[0]?.longitude
|
||||
parsed.country ??= locations?.[0]?.country
|
||||
}
|
||||
}
|
||||
if (parsed.raised_in_city) {
|
||||
if (!parsed.raised_in_lat || !parsed.raised_in_lon) {
|
||||
const result = await searchLocation({term: parsed.raised_in_city, limit: 1})
|
||||
const locations = result.data?.data
|
||||
parsed.raised_in_lat = locations?.[0]?.latitude
|
||||
parsed.raised_in_lon = locations?.[0]?.longitude
|
||||
parsed.raised_in_country ??= locations?.[0]?.country
|
||||
}
|
||||
}
|
||||
if (parsed.links) {
|
||||
const sites = Object.keys(parsed.links).filter((key) => SITE_ORDER.includes(key as any))
|
||||
parsed.links = sites.reduce(
|
||||
(acc, key) => {
|
||||
const link = (parsed.links as Record<string, any>)[key]
|
||||
if (link) acc[key] = link
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, any>,
|
||||
)
|
||||
}
|
||||
|
||||
await setCachedResult(cacheKey, parsed)
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
export async function fetchOnlineProfile(url: string | undefined): Promise<JSONContent> {
|
||||
if (!url) throw APIErrors.badRequest('Content or URL is required')
|
||||
|
||||
try {
|
||||
// 1. Google Docs shortcut
|
||||
const googleDocId = extractGoogleDocId(url)
|
||||
if (googleDocId) {
|
||||
url = `https://docs.google.com/document/d/${googleDocId}/export?format=html`
|
||||
}
|
||||
|
||||
// 2. Fetch with proper headers
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (compatible; bot/1.0)',
|
||||
Accept: 'text/html,text/plain,*/*',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch: ${response.status} ${response.statusText}`)
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? ''
|
||||
const content = await response.text()
|
||||
|
||||
log('Fetched content from URL', {url, contentType, contentLength: content.length})
|
||||
debug({content})
|
||||
|
||||
// 3. Route by content type
|
||||
return convertToJSONContent(content, contentType, url)
|
||||
} catch (error) {
|
||||
log('Error fetching URL', {url, error})
|
||||
throw APIErrors.badRequest('Failed to fetch content from URL')
|
||||
}
|
||||
}
|
||||
|
||||
export const llmExtractProfileEndpoint: APIHandler<'llm-extract-profile'> = async (
|
||||
parsedBody,
|
||||
auth,
|
||||
) => {
|
||||
const {url, locale} = parsedBody
|
||||
let content = parsedBody.content
|
||||
|
||||
log('Extracting profile from content', {
|
||||
contentLength: content?.length,
|
||||
url,
|
||||
locale,
|
||||
userId: auth.uid,
|
||||
})
|
||||
|
||||
if (content && url) {
|
||||
throw APIErrors.badRequest('Content and URL cannot be provided together')
|
||||
}
|
||||
|
||||
let bio
|
||||
if (!content) {
|
||||
bio = await fetchOnlineProfile(url)
|
||||
debug(JSON.stringify(bio, null, 2))
|
||||
content = parseJsonContentToText(bio)
|
||||
}
|
||||
|
||||
const extracted = await callLLM(content, locale)
|
||||
|
||||
if (bio) {
|
||||
extracted.bio = bio
|
||||
}
|
||||
|
||||
debug(JSON.stringify(bio))
|
||||
|
||||
log('Profile extracted successfully', {extracted})
|
||||
|
||||
return extracted
|
||||
}
|
||||
@@ -1,135 +1,137 @@
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body {
|
||||
background-color: #1e1e1e !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
/*@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 p,*/
|
||||
/* h1,*/
|
||||
/* h2,*/
|
||||
/* h3,*/
|
||||
/* h4,*/
|
||||
/* h5,*/
|
||||
/* h6,*/
|
||||
/* label,*/
|
||||
/* .btn,*/
|
||||
/* .info li,*/
|
||||
/* .info li,*/
|
||||
/* .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 .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 {*/
|
||||
/* 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-summary-method {*/
|
||||
/* background-color: #1f1f1f !important;*/
|
||||
/* color: #fff !important;*/
|
||||
/* }*/
|
||||
|
||||
.swagger-ui .opblock .opblock-section-header {
|
||||
background: #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 .responses-wrapper {*/
|
||||
/* background-color: #1f1f1f !important;*/
|
||||
/* }*/
|
||||
|
||||
.swagger-ui .response-col_status {
|
||||
color: #fff !important;
|
||||
}
|
||||
/* .swagger-ui .response-col_status {*/
|
||||
/* color: #fff !important;*/
|
||||
/* }*/
|
||||
|
||||
.swagger-ui .scheme-container {
|
||||
background-color: #1f1f1f !important;
|
||||
}
|
||||
/* .swagger-ui .scheme-container {*/
|
||||
/* background-color: #1f1f1f !important;*/
|
||||
/* }*/
|
||||
|
||||
.swagger-ui .modal-ux,
|
||||
input {
|
||||
background-color: #1f1f1f !important;
|
||||
color: #fff !important;
|
||||
}
|
||||
/* .swagger-ui .modal-ux,*/
|
||||
/* input {*/
|
||||
/* background-color: #1f1f1f !important;*/
|
||||
/* color: #fff !important;*/
|
||||
/* }*/
|
||||
|
||||
.swagger-ui svg path {
|
||||
fill: white !important;
|
||||
}
|
||||
/* .swagger-ui svg path {*/
|
||||
/* fill: white !important;*/
|
||||
/* }*/
|
||||
|
||||
.swagger-ui .close-modal svg {
|
||||
color: #1e90ff !important;
|
||||
}
|
||||
/* .swagger-ui .close-modal svg {*/
|
||||
/* color: #1e90ff !important;*/
|
||||
/* }*/
|
||||
|
||||
a {
|
||||
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;
|
||||
}
|
||||
/*!* 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%;
|
||||
}
|
||||
/* .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;
|
||||
}
|
||||
/* !* 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;
|
||||
}
|
||||
/* !* Headings scale *!*/
|
||||
/* .swagger-ui h1 {*/
|
||||
/* font-size: 1.75rem !important;*/
|
||||
/* }*/
|
||||
|
||||
.swagger-ui h2 {
|
||||
font-size: 1.5rem !important;
|
||||
}
|
||||
/* .swagger-ui h2 {*/
|
||||
/* font-size: 1.5rem !important;*/
|
||||
/* }*/
|
||||
|
||||
.swagger-ui h3 {
|
||||
font-size: 1.25rem !important;
|
||||
}
|
||||
/* .swagger-ui h3 {*/
|
||||
/* font-size: 1.25rem !important;*/
|
||||
/* }*/
|
||||
|
||||
.swagger-ui h4 {
|
||||
font-size: 1.125rem !important;
|
||||
}
|
||||
/* .swagger-ui h4 {*/
|
||||
/* font-size: 1.125rem !important;*/
|
||||
/* }*/
|
||||
|
||||
.swagger-ui h5,
|
||||
.swagger-ui h6 {
|
||||
font-size: 1rem !important;
|
||||
}
|
||||
}
|
||||
/* .swagger-ui h5,*/
|
||||
/* .swagger-ui h6 {*/
|
||||
/* font-size: 1rem !important;*/
|
||||
/* }*/
|
||||
/*}*/
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {broadcastPrivateMessages} from 'api/helpers/private-messages'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {APIErrors, APIHandler} from './helpers/endpoint'
|
||||
|
||||
export const reactToMessage: APIHandler<'react-to-message'> = async (
|
||||
{messageId, reaction, toDelete},
|
||||
@@ -20,7 +20,7 @@ export const reactToMessage: APIHandler<'react-to-message'> = async (
|
||||
)
|
||||
|
||||
if (!message) {
|
||||
throw new APIError(403, 'Not authorized to react to this message')
|
||||
throw APIErrors.forbidden('Not authorized to react to this message')
|
||||
}
|
||||
|
||||
if (toDelete) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {APIError, type APIHandler} from 'api/helpers/endpoint'
|
||||
import {APIErrors, 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'
|
||||
@@ -11,7 +11,7 @@ export const removePinnedPhoto: APIHandler<'remove-pinned-photo'> = async (
|
||||
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 APIErrors.forbidden('Only admins can remove pinned photo')
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
const {error} = await tryCatch(
|
||||
@@ -19,7 +19,7 @@ export const removePinnedPhoto: APIHandler<'remove-pinned-photo'> = async (
|
||||
)
|
||||
|
||||
if (error) {
|
||||
throw new APIError(500, 'Failed to remove pinned photo')
|
||||
throw APIErrors.internalServerError('Failed to remove pinned photo')
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -5,7 +5,7 @@ import {tryCatch} from 'common/util/try-catch'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {insert} from 'shared/supabase/utils'
|
||||
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {APIErrors, 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
|
||||
@@ -27,7 +27,7 @@ export const report: APIHandler<'report'> = async (body, auth) => {
|
||||
)
|
||||
|
||||
if (result.error) {
|
||||
throw new APIError(500, 'Failed to create report: ' + result.error.message)
|
||||
throw APIErrors.internalServerError('Failed to create report: ' + result.error.message)
|
||||
}
|
||||
|
||||
const continuation = async () => {
|
||||
@@ -50,8 +50,8 @@ export const report: APIHandler<'report'> = async (body, auth) => {
|
||||
🚨 **New Report** 🚨
|
||||
**Type:** ${contentType}
|
||||
**Content ID:** ${contentId}
|
||||
**Reporter:** ${reporter?.name} ([@${reporter?.username}](https://www.${DOMAIN}/${reporter?.username}))
|
||||
**Reported:** ${reported?.name} ([@${reported?.username}](https://www.${DOMAIN}/${reported?.username}))
|
||||
**Reporter:** ${reporter?.name} ([@${reporter?.username}](https://${DOMAIN}/${reporter?.username}))
|
||||
**Reported:** ${reported?.name} ([@${reported?.username}](https://${DOMAIN}/${reported?.username}))
|
||||
`
|
||||
await sendDiscordMessage(message, 'reports')
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {APIError, APIHandler} from 'api/helpers/endpoint'
|
||||
import {APIErrors, 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'
|
||||
@@ -19,11 +19,11 @@ export const rsvpEvent: APIHandler<'rsvp-event'> = async (body, auth) => {
|
||||
)
|
||||
|
||||
if (!event) {
|
||||
throw new APIError(404, 'Event not found')
|
||||
throw APIErrors.notFound('Event not found')
|
||||
}
|
||||
|
||||
if (event.status !== 'active') {
|
||||
throw new APIError(400, 'Cannot RSVP to a cancelled or completed event')
|
||||
throw APIErrors.badRequest('Cannot RSVP to a cancelled or completed event')
|
||||
}
|
||||
|
||||
// Check if already RSVPed
|
||||
@@ -47,7 +47,7 @@ export const rsvpEvent: APIHandler<'rsvp-event'> = async (body, auth) => {
|
||||
)
|
||||
|
||||
if (error) {
|
||||
throw new APIError(500, 'Failed to update RSVP: ' + error.message)
|
||||
throw APIErrors.internalServerError('Failed to update RSVP: ' + error.message)
|
||||
}
|
||||
} else {
|
||||
// Check max participants limit
|
||||
@@ -61,7 +61,7 @@ export const rsvpEvent: APIHandler<'rsvp-event'> = async (body, auth) => {
|
||||
)
|
||||
|
||||
if (Number(count.count) >= event.max_participants) {
|
||||
throw new APIError(400, 'Event is at maximum capacity')
|
||||
throw APIErrors.badRequest('Event is at maximum capacity')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ export const rsvpEvent: APIHandler<'rsvp-event'> = async (body, auth) => {
|
||||
)
|
||||
|
||||
if (error) {
|
||||
throw new APIError(500, 'Failed to RSVP: ' + error.message)
|
||||
throw APIErrors.internalServerError('Failed to RSVP: ' + error.message)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {APIErrors, APIHandler} from './helpers/endpoint'
|
||||
|
||||
export const saveSubscriptionMobile: APIHandler<'save-subscription-mobile'> = async (
|
||||
body,
|
||||
@@ -9,7 +9,7 @@ export const saveSubscriptionMobile: APIHandler<'save-subscription-mobile'> = as
|
||||
const {token} = body
|
||||
|
||||
if (!token) {
|
||||
throw new APIError(400, `Invalid subscription object`)
|
||||
throw APIErrors.badRequest('Invalid subscription object')
|
||||
}
|
||||
|
||||
const userId = auth?.uid
|
||||
@@ -28,6 +28,6 @@ export const saveSubscriptionMobile: APIHandler<'save-subscription-mobile'> = as
|
||||
return {success: true}
|
||||
} catch (err) {
|
||||
console.error('Error saving subscription', err)
|
||||
throw new APIError(500, `Failed to save subscription`)
|
||||
throw APIErrors.internalServerError('Failed to save subscription')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {APIErrors, APIHandler} from './helpers/endpoint'
|
||||
|
||||
export const saveSubscription: APIHandler<'save-subscription'> = async (body, auth) => {
|
||||
const {subscription} = body
|
||||
|
||||
if (!subscription?.endpoint || !subscription?.keys) {
|
||||
throw new APIError(400, `Invalid subscription object`)
|
||||
throw APIErrors.badRequest('Invalid subscription object')
|
||||
}
|
||||
|
||||
const userId = auth?.uid
|
||||
@@ -37,6 +37,6 @@ export const saveSubscription: APIHandler<'save-subscription'> = async (body, au
|
||||
return {success: true}
|
||||
} catch (err) {
|
||||
console.error('Error saving subscription', err)
|
||||
throw new APIError(500, `Failed to save subscription`)
|
||||
throw APIErrors.internalServerError('Failed to save subscription')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import {ValidatedAPIParams} from 'common/api/schema'
|
||||
import {geodbFetch} from 'common/geodb'
|
||||
|
||||
import {APIHandler} from './helpers/endpoint'
|
||||
|
||||
export const searchLocation: APIHandler<'search-location'> = async (body) => {
|
||||
export const searchLocationEndpoint: APIHandler<'search-location'> = async (body) => {
|
||||
return await searchLocation(body)
|
||||
}
|
||||
|
||||
export async function searchLocation(body: ValidatedAPIParams<'search-location'>) {
|
||||
const {term, limit} = body
|
||||
const endpoint = `/cities?namePrefix=${term}&limit=${limit ?? 10}&offset=0&sort=-population`
|
||||
// const endpoint = `/countries?namePrefix=${term}&limit=${limit ?? 10}&offset=0`
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {loadProfiles, profileQueryType} from 'api/get-profiles'
|
||||
import {debug} from 'common/logger'
|
||||
import {MatchesByUserType} from 'common/profiles/bookmarked_searches'
|
||||
import {Row} from 'common/supabase/utils'
|
||||
import {sendSearchAlertsEmail} from 'email/functions/helpers'
|
||||
@@ -25,7 +26,7 @@ export const sendSearchNotifications = async () => {
|
||||
[],
|
||||
convertSearchRow,
|
||||
)) as Row<'bookmarked_searches'>[]
|
||||
console.debug(`Running ${searches.length} bookmarked searches`)
|
||||
debug(`Running ${searches.length} bookmarked searches`)
|
||||
|
||||
const _users = (await pg.map(
|
||||
renderSql(select('users.*'), from('users')),
|
||||
@@ -33,7 +34,7 @@ export const sendSearchNotifications = async () => {
|
||||
convertSearchRow,
|
||||
)) as Row<'users'>[]
|
||||
const users = keyBy(_users, 'id')
|
||||
console.debug('users', users)
|
||||
debug('users', users)
|
||||
|
||||
const _privateUsers = (await pg.map(
|
||||
renderSql(select('private_users.*'), from('private_users')),
|
||||
@@ -41,7 +42,7 @@ export const sendSearchNotifications = async () => {
|
||||
convertSearchRow,
|
||||
)) as Row<'private_users'>[]
|
||||
const privateUsers = keyBy(_privateUsers, 'id')
|
||||
console.debug('privateUsers', privateUsers)
|
||||
debug('privateUsers', privateUsers)
|
||||
|
||||
const matches: MatchesByUserType = {}
|
||||
|
||||
@@ -56,7 +57,7 @@ export const sendSearchNotifications = async () => {
|
||||
shortBio: true,
|
||||
}
|
||||
const {profiles} = await loadProfiles(props as profileQueryType)
|
||||
console.debug(profiles.map((item: any) => item.name))
|
||||
debug(profiles.map((item: any) => item.name))
|
||||
if (!profiles.length) continue
|
||||
if (!(row.creator_id in matches)) {
|
||||
if (!privateUsers[row.creator_id]) continue
|
||||
@@ -75,7 +76,7 @@ export const sendSearchNotifications = async () => {
|
||||
})),
|
||||
})
|
||||
}
|
||||
console.debug('matches:', JSON.stringify(matches, null, 2))
|
||||
debug('matches:', JSON.stringify(matches, null, 2))
|
||||
await notifyBookmarkedSearch(matches)
|
||||
|
||||
return {status: 'success'}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'tsconfig-paths/register'
|
||||
|
||||
import {IS_LOCAL} from 'common/hosting/constants'
|
||||
import * as Sentry from '@sentry/node'
|
||||
import {IS_LOCAL, SENTRY_DSN} from 'common/hosting/constants'
|
||||
import {loadSecretsToEnv} from 'common/secrets'
|
||||
import {ErrorRequestHandler} from 'express'
|
||||
import * as admin from 'firebase-admin'
|
||||
import {getServiceAccountCredentials} from 'shared/firebase-utils'
|
||||
import {initAdmin} from 'shared/init-admin'
|
||||
@@ -13,6 +15,27 @@ import {app} from './app'
|
||||
|
||||
log('Api server starting up....')
|
||||
|
||||
Sentry.init({
|
||||
dsn: SENTRY_DSN,
|
||||
enabled: process.env.NODE_ENV === 'production',
|
||||
environment: process.env.NODE_ENV,
|
||||
tracesSampleRate: process.env.NODE_ENV === 'production' ? 0.1 : 1.0,
|
||||
enableLogs: process.env.NODE_ENV === 'production',
|
||||
})
|
||||
|
||||
const sentryErrorFilter: ErrorRequestHandler = (err, req, _res, next) => {
|
||||
const status = err.status ?? err.httpStatus ?? 500
|
||||
if (status >= 500) {
|
||||
Sentry.captureException(err, {
|
||||
extra: {path: req.path, method: req.method, status},
|
||||
})
|
||||
}
|
||||
next(err)
|
||||
}
|
||||
|
||||
app.use(sentryErrorFilter)
|
||||
app.use(Sentry.expressErrorHandler())
|
||||
|
||||
if (IS_LOCAL) {
|
||||
initAdmin()
|
||||
} else {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import {Row} from 'common/supabase/utils'
|
||||
import {recomputeCompatibilityScoresForUser} from 'shared/compatibility/compute-scores'
|
||||
import {
|
||||
recomputeCompatibilityScoresForUser,
|
||||
updateCompatibilityPromptsMetrics,
|
||||
} from 'shared/compatibility/compute-scores'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
import {APIHandler} from './helpers/endpoint'
|
||||
@@ -26,8 +29,8 @@ export const setCompatibilityAnswer: APIHandler<'set-compatibility-answer'> = as
|
||||
})
|
||||
|
||||
const continuation = async () => {
|
||||
// Recompute precomputed compatibility scores for this user
|
||||
await recomputeCompatibilityScoresForUser(auth.uid, pg)
|
||||
await updateCompatibilityPromptsMetrics(questionId)
|
||||
await recomputeCompatibilityScoresForUser(auth.uid)
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -4,7 +4,7 @@ import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {insert} from 'shared/supabase/utils'
|
||||
import {log} from 'shared/utils'
|
||||
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {APIErrors, APIHandler} from './helpers/endpoint'
|
||||
|
||||
export const shipProfiles: APIHandler<'ship-profiles'> = async (props, auth) => {
|
||||
const {targetUserId1, targetUserId2, remove} = props
|
||||
@@ -25,7 +25,8 @@ export const shipProfiles: APIHandler<'ship-profiles'> = async (props, auth) =>
|
||||
),
|
||||
)
|
||||
|
||||
if (existing.error) throw new APIError(500, 'Error when checking ship: ' + existing.error.message)
|
||||
if (existing.error)
|
||||
throw APIErrors.internalServerError('Error when checking ship: ' + existing.error.message)
|
||||
|
||||
if (existing.data) {
|
||||
if (remove) {
|
||||
@@ -33,7 +34,7 @@ export const shipProfiles: APIHandler<'ship-profiles'> = async (props, auth) =>
|
||||
pg.none('delete from profile_ships where ship_id = $1', [existing.data.ship_id]),
|
||||
)
|
||||
if (error) {
|
||||
throw new APIError(500, 'Failed to remove ship: ' + error.message)
|
||||
throw APIErrors.internalServerError('Failed to remove ship: ' + error.message)
|
||||
}
|
||||
} else {
|
||||
log('Ship already exists, do nothing')
|
||||
@@ -51,7 +52,7 @@ export const shipProfiles: APIHandler<'ship-profiles'> = async (props, auth) =>
|
||||
)
|
||||
|
||||
if (error) {
|
||||
throw new APIError(500, 'Failed to create ship: ' + error.message)
|
||||
throw APIErrors.internalServerError('Failed to create ship: ' + error.message)
|
||||
}
|
||||
|
||||
const continuation = async () => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {insert} from 'shared/supabase/utils'
|
||||
import {log} from 'shared/utils'
|
||||
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {APIErrors, APIHandler} from './helpers/endpoint'
|
||||
|
||||
export const starProfile: APIHandler<'star-profile'> = async (props, auth) => {
|
||||
const {targetUserId, remove} = props
|
||||
@@ -21,7 +21,7 @@ export const starProfile: APIHandler<'star-profile'> = async (props, auth) => {
|
||||
)
|
||||
|
||||
if (error) {
|
||||
throw new APIError(500, 'Failed to remove star: ' + error.message)
|
||||
throw APIErrors.internalServerError('Failed to remove star: ' + error.message)
|
||||
}
|
||||
return {status: 'success'}
|
||||
}
|
||||
@@ -45,7 +45,7 @@ export const starProfile: APIHandler<'star-profile'> = async (props, auth) => {
|
||||
)
|
||||
|
||||
if (error) {
|
||||
throw new APIError(500, 'Failed to add star: ' + error.message)
|
||||
throw APIErrors.internalServerError('Failed to add star: ' + error.message)
|
||||
}
|
||||
|
||||
return {status: 'success'}
|
||||
|
||||
66
backend/api/src/stats.ts
Normal file
66
backend/api/src/stats.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import {getMessagesCount} from 'api/get-messages-count'
|
||||
import {HOUR_MS} from 'common/util/time'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
import {APIHandler} from './helpers/endpoint'
|
||||
|
||||
// Server-side cache for stats data
|
||||
let cachedData: any = null
|
||||
let cacheTimestamp: number = 0
|
||||
const CACHE_DURATION_MS = HOUR_MS
|
||||
|
||||
export const stats: APIHandler<'stats'> = async (_, _auth) => {
|
||||
const now = Date.now()
|
||||
|
||||
// Return cached data if still valid
|
||||
if (cachedData && now - cacheTimestamp < CACHE_DURATION_MS) {
|
||||
console.log('cached stats')
|
||||
console.log(cachedData)
|
||||
return cachedData
|
||||
}
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const [userCount, profileCount, eventsCount, messagesCount, genderStats] = await Promise.all([
|
||||
pg.one(`SELECT COUNT(*)::int as count FROM users`),
|
||||
pg.one(`SELECT COUNT(*)::int as count FROM profiles`),
|
||||
pg.one(
|
||||
`SELECT COUNT(*)::int as count FROM events WHERE event_start_time > now() and status = 'active'`,
|
||||
),
|
||||
getMessagesCount(),
|
||||
pg.manyOrNone(
|
||||
`SELECT gender, COUNT(*)::int as count FROM profiles WHERE gender IS NOT NULL GROUP BY gender`,
|
||||
),
|
||||
])
|
||||
|
||||
// Calculate gender ratios
|
||||
const genderRatio: Record<string, number> = {}
|
||||
let totalWithGender = 0
|
||||
|
||||
genderStats?.forEach((stat: any) => {
|
||||
if (!['male', 'female'].includes(stat.gender)) return
|
||||
genderRatio[stat.gender] = stat.count
|
||||
totalWithGender += stat.count
|
||||
})
|
||||
|
||||
// Convert to percentages
|
||||
const genderPercentage: Record<string, number> = {}
|
||||
Object.entries(genderRatio).forEach(([gender, count]) => {
|
||||
genderPercentage[gender] = Math.round((count / totalWithGender) * 100)
|
||||
})
|
||||
|
||||
const result = {
|
||||
users: userCount.count,
|
||||
profiles: profileCount.count,
|
||||
upcomingEvents: eventsCount.count,
|
||||
messages: messagesCount.count,
|
||||
genderRatio: genderPercentage,
|
||||
genderCounts: genderRatio,
|
||||
}
|
||||
|
||||
// Update cache
|
||||
cachedData = result
|
||||
cacheTimestamp = now
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import {debug} from 'common/logger'
|
||||
import {sendTestEmail} from 'email/functions/helpers'
|
||||
|
||||
export const localSendTestEmail = async () => {
|
||||
sendTestEmail('hello@compassmeet.com')
|
||||
.then(() => console.debug('Email sent successfully!'))
|
||||
.then(() => debug('Email sent successfully!'))
|
||||
.catch((error) => console.error('Failed to send email:', error))
|
||||
return {message: 'Email sent successfully!'}
|
||||
}
|
||||
|
||||
28
backend/api/src/update-compatibility-question-pin.ts
Normal file
28
backend/api/src/update-compatibility-question-pin.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import {getPinnedQuestionIds} from 'api/get-pinned-compatibility-questions'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
import {type APIHandler} from './helpers/endpoint'
|
||||
|
||||
export const updateCompatibilityQuestionPin: APIHandler<
|
||||
'update-compatibility-question-pin'
|
||||
> = async ({questionId, pinned}, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
if (pinned) {
|
||||
await pg.none(
|
||||
`insert into compatibility_prompts_pinned (user_id, question_id)
|
||||
values ($1, $2)
|
||||
on conflict (user_id, question_id) do nothing`,
|
||||
[auth.uid, questionId],
|
||||
)
|
||||
} else {
|
||||
await pg.none(
|
||||
`delete from compatibility_prompts_pinned
|
||||
where user_id = $1 and question_id = $2`,
|
||||
[auth.uid, questionId],
|
||||
)
|
||||
}
|
||||
|
||||
const pinnedQuestionIds = await getPinnedQuestionIds(auth.uid)
|
||||
return {status: 'success', pinnedQuestionIds}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user