mirror of
https://github.com/CompassConnections/Compass.git
synced 2026-04-04 06:51:45 -04:00
Compare commits
231 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
204a35d026 | ||
|
|
fb2841f198 | ||
|
|
5de055c977 | ||
|
|
084659ea3d | ||
|
|
c1a414afab | ||
|
|
a5747034d6 | ||
|
|
fda52fec97 | ||
|
|
e38ec79618 | ||
|
|
1ef125db12 | ||
|
|
b580b640bd | ||
|
|
214bddaca4 | ||
|
|
065d489869 | ||
|
|
46ffefbbb9 | ||
|
|
a19db3bca9 | ||
|
|
2c8d8d9989 | ||
|
|
d52943e31e | ||
|
|
3eababb742 | ||
|
|
8a954d3c20 | ||
|
|
8516901032 | ||
|
|
3f2d246fec | ||
|
|
58fdaa26ca | ||
|
|
7dc1a8790d | ||
|
|
70c9ec1d73 | ||
|
|
2bcbbc96ad | ||
|
|
527d36a159 | ||
|
|
2ce21247ee | ||
|
|
8ea6c406e0 | ||
|
|
e22f50ecd3 | ||
|
|
20dcd98fdf | ||
|
|
bc5708857a | ||
|
|
b9c045ebfb | ||
|
|
c69bd7018e | ||
|
|
078d149175 | ||
|
|
be9f0bd061 | ||
|
|
a4723563f5 | ||
|
|
1fdcd24f28 | ||
|
|
a43480db92 | ||
|
|
e85a072f1c | ||
|
|
bbfa2a4eab | ||
|
|
2f2db4ded8 | ||
|
|
7296a0d2cd | ||
|
|
08e02b6ac0 | ||
|
|
715811d7fd | ||
|
|
c7d6ae6995 | ||
|
|
b1d1396944 | ||
|
|
25a319710e | ||
|
|
796b13dd62 | ||
|
|
8197863ac5 | ||
|
|
89bd164d43 | ||
|
|
80d7061e5f | ||
|
|
c49bac3a09 | ||
|
|
06d53fe801 | ||
|
|
15ba529938 | ||
|
|
83054d0cd1 | ||
|
|
8da486adf2 | ||
|
|
32bc3847fa | ||
|
|
5d763c18c8 | ||
|
|
bd3920cfff | ||
|
|
06d94332b6 | ||
|
|
50614484d8 | ||
|
|
c29d3d8c92 | ||
|
|
26f46af375 | ||
|
|
32b1491dd0 | ||
|
|
51b8a6c80a | ||
|
|
0f63d6d3a0 | ||
|
|
4771b08773 | ||
|
|
9b880101fd | ||
|
|
594806d6e8 | ||
|
|
e9afd4db2f | ||
|
|
b23efe4089 | ||
|
|
e33be41a93 | ||
|
|
33b09df872 | ||
|
|
e9050d0aa0 | ||
|
|
baeb2a33fe | ||
|
|
4ad89acdc7 | ||
|
|
7d87af8f5c | ||
|
|
65c0e84e2a | ||
|
|
7b15d85871 | ||
|
|
ad8ec0f4fd | ||
|
|
2d05d83dd0 | ||
|
|
bd45066b13 | ||
|
|
8ee4274054 | ||
|
|
83a7ed4d6b | ||
|
|
07dbd86ac6 | ||
|
|
0e671d2cc0 | ||
|
|
2d6d3c04ce | ||
|
|
b0148963c7 | ||
|
|
13356950f3 | ||
|
|
629bcb30a7 | ||
|
|
03721fff1c | ||
|
|
2a6911ae3d | ||
|
|
164eddecab | ||
|
|
9eacb38eb9 | ||
|
|
20f5cfb9a7 | ||
|
|
6c6c1cc90a | ||
|
|
a32c099cc1 | ||
|
|
fe2f832e83 | ||
|
|
868746cc23 | ||
|
|
3be7a54284 | ||
|
|
635e1ec8e2 | ||
|
|
a638a35a76 | ||
|
|
8cc33d3418 | ||
|
|
9947f7b967 | ||
|
|
daf5350f41 | ||
|
|
020b9ddb8d | ||
|
|
23aff9497a | ||
|
|
3c119396f3 | ||
|
|
f7c7c47ac0 | ||
|
|
dbe2369bbe | ||
|
|
4e8033d221 | ||
|
|
97a0f87cbd | ||
|
|
bfa2713d43 | ||
|
|
fe5e109751 | ||
|
|
8cc96030b1 | ||
|
|
a2b172ad58 | ||
|
|
e756225d8b | ||
|
|
dd803b604f | ||
|
|
b5c961c8ee | ||
|
|
47cd9d227e | ||
|
|
e2be3aafcd | ||
|
|
015fe76c44 | ||
|
|
44666aec03 | ||
|
|
6a265e4f35 | ||
|
|
12c7316524 | ||
|
|
dcf9741d69 | ||
|
|
63dd1fdd50 | ||
|
|
5aa166bbfd | ||
|
|
34cbf7093e | ||
|
|
159d58949e | ||
|
|
fcf802b7e3 | ||
|
|
92ff6dadb0 | ||
|
|
05fa2f9883 | ||
|
|
71bb8fd784 | ||
|
|
16ffd6dfab | ||
|
|
2661d15910 | ||
|
|
394102bb93 | ||
|
|
3585b12dfd | ||
|
|
423d87d5f1 | ||
|
|
13b13b1104 | ||
|
|
a77e7b96b7 | ||
|
|
d7213c255c | ||
|
|
ddeb1dcdb7 | ||
|
|
221cfa3528 | ||
|
|
d6f6348ff1 | ||
|
|
0c6afdc98e | ||
|
|
02a2148b3f | ||
|
|
36a02268d8 | ||
|
|
450f07f505 | ||
|
|
777eba9fed | ||
|
|
eaa8fa57d1 | ||
|
|
200bf479e1 | ||
|
|
331f409af9 | ||
|
|
ce875a5e63 | ||
|
|
638013f835 | ||
|
|
1de87cbfec | ||
|
|
7f3428b36a | ||
|
|
35595ded47 | ||
|
|
35e9264017 | ||
|
|
02d33c8f83 | ||
|
|
f229ebc3a8 | ||
|
|
0062351f6d | ||
|
|
e86f6798ec | ||
|
|
4f53f7136b | ||
|
|
d80b982dde | ||
|
|
24788aa9af | ||
|
|
9ffae658df | ||
|
|
82ad573cac | ||
|
|
36bf7ad65b | ||
|
|
b30af128c7 | ||
|
|
72c31ae097 | ||
|
|
d2c608021d | ||
|
|
1f36fb2413 | ||
|
|
16a0cbcecf | ||
|
|
e068e246aa | ||
|
|
ec7c77fcf9 | ||
|
|
46a338b874 | ||
|
|
bfee7ff09d | ||
|
|
ce1305d8ae | ||
|
|
aaebf88438 | ||
|
|
dde2c99e36 | ||
|
|
4dc2f3b9b9 | ||
|
|
f30cfffb86 | ||
|
|
ca3eb62ba7 | ||
|
|
c8e55ca4ce | ||
|
|
e4acb25a40 | ||
|
|
c741e10139 | ||
|
|
28d0b35f8e | ||
|
|
f7f09cd9e5 | ||
|
|
501c92c350 | ||
|
|
f021101322 | ||
|
|
369265bc2c | ||
|
|
b1f1e5db1f | ||
|
|
51d32e5afb | ||
|
|
f396e8e482 | ||
|
|
077321731e | ||
|
|
60eb0c6978 | ||
|
|
475f0af78a | ||
|
|
206fa07035 | ||
|
|
aff949714c | ||
|
|
7e834b9ff6 | ||
|
|
19bad26a98 | ||
|
|
7cc7c8d27b | ||
|
|
ae5a8c7cfa | ||
|
|
5004b73210 | ||
|
|
02f613d269 | ||
|
|
439ac0310b | ||
|
|
3e95467819 | ||
|
|
90522cb88b | ||
|
|
af39b01d4a | ||
|
|
73a0a5ff0b | ||
|
|
e157f500bc | ||
|
|
274ee5ed5f | ||
|
|
4cb11ba8c0 | ||
|
|
7b8e775139 | ||
|
|
86a7d26bfd | ||
|
|
84a437772d | ||
|
|
d7c95e2ae0 | ||
|
|
b4f0ef8b43 | ||
|
|
6d30cd7ae4 | ||
|
|
f631236ee7 | ||
|
|
1a58ff5c4c | ||
|
|
73aca913a1 | ||
|
|
24dee0cad6 | ||
|
|
2d2de75372 | ||
|
|
d98982e6fd | ||
|
|
14c12ffb08 | ||
|
|
f260afca11 | ||
|
|
5bcbe25d97 | ||
|
|
2eee366fbd | ||
|
|
85d57ec5e6 | ||
|
|
502c878f82 |
23
.env.example
23
.env.example
@@ -1,20 +1,7 @@
|
||||
# Rename this file to `.env` and fill in the values.
|
||||
# You already have access to basic local functionality (UI, authentication, database read access).
|
||||
|
||||
# Optional variables for the backend server functionality (modifying user data, etc.)
|
||||
|
||||
# For database write access (dev).
|
||||
# A 16-character password with digits and letters.
|
||||
SUPABASE_DB_PASSWORD=09wATRREfAzyL5pc
|
||||
|
||||
# For Firebase access.
|
||||
# Open a GitHub issue with your contribution ideas and an admin will give you the key.
|
||||
# TODO: find a way to give anyone moderate access to dev firebase.
|
||||
GOOGLE_APPLICATION_CREDENTIALS_DEV="[...].json"
|
||||
|
||||
# The URL where your local backend server is running.
|
||||
# You can change the port if needed.
|
||||
NEXT_PUBLIC_API_URL=localhost:8088
|
||||
# openssl enc -aes-256-cbc -salt -pbkdf2 -iter 100000 -in backend/shared/src/googleApplicationCredentials-dev.json -out secrets/googleApplicationCredentials-dev.json.enc
|
||||
GOOGLE_CREDENTIALS_ENC_PWD=nP7s3274uzOG4c2t
|
||||
|
||||
|
||||
# Optional variables for full local functionality
|
||||
@@ -23,10 +10,6 @@ NEXT_PUBLIC_API_URL=localhost:8088
|
||||
# Create a free account at https://rapidapi.com/wirefreethought/api/geodb-cities and get an API key.
|
||||
GEODB_API_KEY=
|
||||
|
||||
# For analytics like page views, user actions, feature usage, etc.
|
||||
# Create a free account at https://posthog.com and get a project API key. Should start with "phc_".
|
||||
POSTHOG_KEY=
|
||||
|
||||
# For sending emails (e.g. for user sign up, password reset, notifications, etc.).
|
||||
# Create a free account at https://resend.com and get an API key. Should start with "re_".
|
||||
RESEND_API_KEY=
|
||||
RESEND_KEY=
|
||||
|
||||
15
.github/FUNDING.yml
vendored
Normal file
15
.github/FUNDING.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
|
||||
patreon: CompassMeet # Replace with a single Patreon username
|
||||
open_collective: # Replace with a single Open Collective username
|
||||
ko_fi: compassconnections # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -79,3 +79,10 @@ email-preview
|
||||
*.zip
|
||||
*.tar.gz
|
||||
*.rar
|
||||
/favicon_color.ico
|
||||
/backend/shared/src/googleApplicationCredentials-dev.json
|
||||
*.tfstate
|
||||
*.tfstate.backup
|
||||
*.terraform
|
||||
/backups/firebase/auth/data/
|
||||
/backups/firebase/storage/data/
|
||||
|
||||
46
README.md
46
README.md
@@ -21,11 +21,28 @@ This repository contains the source code for [Compass](https://compassmeet.com)
|
||||
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).
|
||||
|
||||
<p style="text-align: center;">
|
||||
<img src="https://firebasestorage.googleapis.com/v0/b/compass-130ba.firebasestorage.app/o/misc%2Fdemo_compass.gif?alt=media&token=e3ae4334-4e3f-4026-b121-c08b4b724cd1" alt="Compass Demo" width="600">
|
||||
</p>
|
||||
|
||||
## 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**!
|
||||
|
||||
Here are some examples 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).
|
||||
The complete, official list of tasks is available [here on ClickUp](https://sharing.clickup.com/90181043445/l/h/6-901810339879-1/bbfd32f4f4bf64b). If you are working on one task, just assign it to yourself and move its status to "in progress". If there is also a GitHub issue for that task, assign it to yourself as well.
|
||||
|
||||
To have edit access to the ClickUp workspace, you need an admin to manually give you permission (one time thing). To do so, just use your preferred option:
|
||||
- Ask or DM an admin on Discord
|
||||
- Email hello@compassmeet.com
|
||||
- Raise an issue on GitHub
|
||||
|
||||
If you want to add tasks without creating an account, you can simply email
|
||||
```
|
||||
a.t.901810339879.u-276866260.b847aba1-2709-4f17-b4dc-565a6967c234@tasks.clickup.com
|
||||
```
|
||||
Put the task title in the email subject and the task description in the email content.
|
||||
|
||||
Here is a tailored selection of things that would be very useful. If you want to help but don’t know where to start, just ask us on [Discord](https://discord.gg/8Vd7jzqjun).
|
||||
|
||||
- [x] Authentication (user/password and Google Sign In)
|
||||
- [x] Set up PostgreSQL in Production with supabase
|
||||
@@ -58,7 +75,7 @@ Everything is open to anyone for collaboration, but the following ones are parti
|
||||
- [ ] Add other authentication methods (GitHub, Facebook, Apple, phone, etc.)
|
||||
- [ ] Add email verification
|
||||
- [ ] Add password reset
|
||||
- [ ] Add automated welcome email
|
||||
- [x] Add automated welcome email
|
||||
- [ ] Security audit and penetration testing
|
||||
- [ ] Make `deploy-api.sh` run automatically on push to `main` branch
|
||||
- [ ] Create settings page (change email, password, delete account, etc.)
|
||||
@@ -100,20 +117,17 @@ yarn install
|
||||
|
||||
### 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.
|
||||
|
||||
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 our paid plan
|
||||
- Email, analytics, and location services, otherwise anyone could use the service plans Compass paid for and run up the bill.
|
||||
|
||||
We separate all those services between production and local development, so that you can code freely without impacting the functioning of the platform.
|
||||
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.
|
||||
|
||||
Most of the code will work out of the box. All you need to do is creating an `.env` file as a copy of `.env.example`:
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
If you do need one of the few remaining services, you need to store your own secrets as environment variables. To do so, simply open `.env` and fill in the variables according to the instructions in the file.
|
||||
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.
|
||||
|
||||
### Tests
|
||||
|
||||
@@ -132,10 +146,22 @@ 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.
|
||||
|
||||
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!
|
||||
|
||||
### Contributing
|
||||
|
||||
Now you can start contributing by making changes and submitting pull requests!
|
||||
|
||||
We recommend using a good code editor (VSCode, WebStorm, Cursor, etc.) with Typescript support and a good AI assistant (GitHub Copilot, etc.) to make your life easier. To debug, you can use the browser developer tools (F12), specifically:
|
||||
- Components tab to see the React component tree and props (you need to install the [React Developer Tools](https://react.dev/learn/react-developer-tools) extension)
|
||||
- Console tab for errors and logs
|
||||
- Network tab to see the requests and responses
|
||||
- Storage tab to see cookies and local storage
|
||||
|
||||
You can also add `console.log()` statements in the code.
|
||||
|
||||
If you are new to Typescript or the open-source space, you could start with small changes, such as tweaking some web components or improving wording in some pages. You can find those files in `web/public/md/`.
|
||||
|
||||
See [development.md](docs/development.md) for additional instructions, such as adding new profile features.
|
||||
|
||||
### Submission
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import {aColor, supportEmail} from "@/lib/client/constants";
|
||||
import Image from 'next/image';
|
||||
|
||||
export default function PrivacyPage() {
|
||||
2
_old/lib/client/constants.tsx
Normal file
2
_old/lib/client/constants.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
'use client';
|
||||
|
||||
@@ -54,6 +54,9 @@ gcloud projects add-iam-policy-binding compass-130ba \
|
||||
--member="serviceAccount:253367029065-compute@developer.gserviceaccount.com" \
|
||||
--role="roles/secretmanager.secretAccessor"
|
||||
gcloud run services list
|
||||
gcloud compute backend-services update api-backend \
|
||||
--global \
|
||||
--timeout=600s
|
||||
```
|
||||
|
||||
Set up the saved search notifications job:
|
||||
|
||||
@@ -185,29 +185,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 {
|
||||
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
|
||||
# }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"verify": "yarn --cwd=../.. verify",
|
||||
"verify:dir": "npx eslint . --max-warnings 0",
|
||||
"regen-types": "cd ../supabase && make ENV=prod regen-types",
|
||||
"regen-types-dev": "cd ../supabase && make ENV=dev regen-types"
|
||||
"regen-types-dev": "cd ../supabase && make ENV=dev regen-types-dev"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
|
||||
@@ -50,9 +50,15 @@ import {leavePrivateUserMessageChannel} from './leave-private-user-message-chann
|
||||
import {updatePrivateUserMessageChannel} from './update-private-user-message-channel'
|
||||
import {getNotifications} from './get-notifications'
|
||||
import {updateNotifSettings} from './update-notif-setting'
|
||||
import {setLastOnlineTime} from './set-last-online-time'
|
||||
import swaggerUi from "swagger-ui-express"
|
||||
import * as fs from "fs"
|
||||
import {sendSearchNotifications} from "api/send-search-notifications";
|
||||
import {sendDiscordMessage} from "common/discord/core";
|
||||
import {getMessagesCount} from "api/get-messages-count";
|
||||
import {createVote} from "api/create-vote";
|
||||
import {vote} from "api/vote";
|
||||
import {contact} from "api/contact";
|
||||
|
||||
const allowCorsUnrestricted: RequestHandler = cors({})
|
||||
|
||||
@@ -108,7 +114,7 @@ swaggerDocument.info = {
|
||||
version: "1.0.0",
|
||||
contact: {
|
||||
name: "Compass",
|
||||
email: "compass.meet.info@gmail.com",
|
||||
email: "hello@compassmeet.com",
|
||||
url: "https://compassmeet.com"
|
||||
}
|
||||
};
|
||||
@@ -153,6 +159,9 @@ const handlers: { [k in APIPath]: APIHandler<k> } = {
|
||||
'create-comment': createComment,
|
||||
'hide-comment': hideComment,
|
||||
'create-compatibility-question': createCompatibilityQuestion,
|
||||
'create-vote': createVote,
|
||||
'vote': vote,
|
||||
'contact': contact,
|
||||
'compatible-profiles': getCompatibleProfilesHandler,
|
||||
'search-location': searchLocation,
|
||||
'search-near-city': searchNearCity,
|
||||
@@ -164,6 +173,8 @@ const handlers: { [k in APIPath]: APIHandler<k> } = {
|
||||
'get-channel-messages': getChannelMessages,
|
||||
'get-channel-seen-time': getLastSeenChannelTime,
|
||||
'set-channel-seen-time': setChannelLastSeenTime,
|
||||
'get-messages-count': getMessagesCount,
|
||||
'set-last-online-time': setLastOnlineTime,
|
||||
}
|
||||
|
||||
Object.entries(handlers).forEach(([path, handler]) => {
|
||||
@@ -191,7 +202,7 @@ Object.entries(handlers).forEach(([path, handler]) => {
|
||||
}
|
||||
})
|
||||
|
||||
// console.log('COMPASS_API_KEY:', process.env.COMPASS_API_KEY)
|
||||
// console.debug('COMPASS_API_KEY:', process.env.COMPASS_API_KEY)
|
||||
|
||||
// Internal Endpoints
|
||||
app.post(pathWithPrefix("/internal/send-search-notifications"),
|
||||
@@ -206,6 +217,10 @@ app.post(pathWithPrefix("/internal/send-search-notifications"),
|
||||
return res.status(200).json(result)
|
||||
} catch (err) {
|
||||
console.error("Failed to send notifications:", err);
|
||||
await sendDiscordMessage(
|
||||
"Failed to send [daily notifications](https://console.cloud.google.com/cloudscheduler?project=compass-130ba) for bookmarked searches...",
|
||||
"health"
|
||||
)
|
||||
return res.status(500).json({error: "Internal server error"});
|
||||
}
|
||||
}
|
||||
|
||||
41
backend/api/src/contact.ts
Normal file
41
backend/api/src/contact.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {insert} from 'shared/supabase/utils'
|
||||
import {tryCatch} from 'common/util/try-catch'
|
||||
import {sendDiscordMessage} from "common/discord/core";
|
||||
import {jsonToMarkdown} from "common/md";
|
||||
|
||||
// Stores a contact message into the `contact` table
|
||||
// Web sends TipTap JSON in `content`; we store it as string in `description`.
|
||||
// If optional content metadata is provided, we include it; otherwise we fall back to user-centric defaults.
|
||||
export const contact: APIHandler<'contact'> = async (
|
||||
{content, userId},
|
||||
_auth
|
||||
) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const {error} = await tryCatch(
|
||||
insert(pg, 'contact', {
|
||||
user_id: userId,
|
||||
content: JSON.stringify(content),
|
||||
})
|
||||
)
|
||||
|
||||
if (error) throw new APIError(500, 'Failed to submit contact message')
|
||||
|
||||
const continuation = async () => {
|
||||
try {
|
||||
const md = jsonToMarkdown(content)
|
||||
const message: string = `**New Contact Message**\n${md}`
|
||||
await sendDiscordMessage(message, 'contact')
|
||||
} catch (e) {
|
||||
console.error('Failed to send discord contact', e)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {},
|
||||
continue: continuation,
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import { track } from 'shared/analytics'
|
||||
import { updateUser } from 'shared/supabase/users'
|
||||
import { tryCatch } from 'common/util/try-catch'
|
||||
import { insert } from 'shared/supabase/utils'
|
||||
import {sendDiscordMessage} from "common/discord/core";
|
||||
import {jsonToMarkdown} from "common/md";
|
||||
|
||||
export const createProfile: APIHandler<'create-profile'> = async (body, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
@@ -28,7 +30,7 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
|
||||
updateUser(pg, auth.uid, { avatarUrl: body.pinned_url })
|
||||
}
|
||||
|
||||
console.log('body', body)
|
||||
console.debug('body', body)
|
||||
|
||||
const { data, error } = await tryCatch(
|
||||
insert(pg, 'profiles', { user_id: auth.uid, ...body })
|
||||
@@ -40,7 +42,51 @@ export const createProfile: APIHandler<'create-profile'> = async (body, auth) =>
|
||||
}
|
||||
|
||||
log('Created user', data)
|
||||
await track(user.id, 'create profile', { username: user.username })
|
||||
|
||||
return data
|
||||
const continuation = async () => {
|
||||
try {
|
||||
await track(auth.uid, 'create profile', {username: user.username})
|
||||
} catch (e) {
|
||||
console.error('Failed to track create profile', e)
|
||||
}
|
||||
try {
|
||||
let message: string = `[**${user.name}**](https://www.compassmeet.com/${user.username}) just created a profile`
|
||||
if (body.bio) {
|
||||
const bioText = jsonToMarkdown(body.bio)
|
||||
if (bioText) message += `\n${bioText}`
|
||||
}
|
||||
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
|
||||
)
|
||||
}
|
||||
console.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: data,
|
||||
continue: continuation,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
import * as admin from 'firebase-admin'
|
||||
import { PrivateUser } from 'common/user'
|
||||
import { randomString } from 'common/util/random'
|
||||
import { cleanDisplayName, cleanUsername } from 'common/util/clean-username'
|
||||
import { getIp, track } from 'shared/analytics'
|
||||
import { APIError, APIHandler } from './helpers/endpoint'
|
||||
import { getDefaultNotificationPreferences } from 'common/user-notification-preferences'
|
||||
import { removeUndefinedProps } from 'common/util/object'
|
||||
import { generateAvatarUrl } from 'shared/helpers/generate-and-update-avatar-urls'
|
||||
import { getStorage } from 'firebase-admin/storage'
|
||||
import { DEV_CONFIG } from 'common/envs/dev'
|
||||
import { PROD_CONFIG } from 'common/envs/prod'
|
||||
import { RESERVED_PATHS } from 'common/envs/constants'
|
||||
import { log, isProd, getUser, getUserByUsername } from 'shared/utils'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { insert } from 'shared/supabase/utils'
|
||||
import { convertPrivateUser, convertUser } from 'common/supabase/users'
|
||||
import {PrivateUser} from 'common/user'
|
||||
import {randomString} from 'common/util/random'
|
||||
import {cleanDisplayName, cleanUsername} from 'common/util/clean-username'
|
||||
import {getIp, track} from 'shared/analytics'
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {getDefaultNotificationPreferences} from 'common/user-notification-preferences'
|
||||
import {removeUndefinedProps} from 'common/util/object'
|
||||
import {generateAvatarUrl} from 'shared/helpers/generate-and-update-avatar-urls'
|
||||
import {IS_LOCAL, RESERVED_PATHS} from 'common/envs/constants'
|
||||
import {getUser, getUserByUsername, log} from 'shared/utils'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {insert} from 'shared/supabase/utils'
|
||||
import {convertPrivateUser, convertUser} from 'common/supabase/users'
|
||||
import {getBucket} from "shared/firebase-utils";
|
||||
import {sendWelcomeEmail} from "email/functions/helpers";
|
||||
|
||||
export const createUser: APIHandler<'create-user'> = async (
|
||||
props,
|
||||
auth,
|
||||
req
|
||||
) => {
|
||||
const { deviceToken: preDeviceToken } = props
|
||||
const {deviceToken: preDeviceToken} = props
|
||||
const firebaseUser = await admin.auth().getUser(auth.uid)
|
||||
|
||||
const testUserAKAEmailPasswordUser =
|
||||
@@ -52,7 +51,7 @@ export const createUser: APIHandler<'create-user'> = async (
|
||||
const rawName = fbUser.displayName || emailName || 'User' + randomString(4)
|
||||
const name = cleanDisplayName(rawName)
|
||||
|
||||
const bucket = getStorage().bucket(getStorageBucketId())
|
||||
const bucket = getBucket()
|
||||
const avatarUrl = fbUser.photoURL
|
||||
? fbUser.photoURL
|
||||
: await generateAvatarUrl(auth.uid, name, bucket)
|
||||
@@ -63,7 +62,9 @@ export const createUser: APIHandler<'create-user'> = async (
|
||||
|
||||
// Check username case-insensitive
|
||||
const dupes = await pg.one<number>(
|
||||
`select count(*) from users where username ilike $1`,
|
||||
`select count(*)
|
||||
from users
|
||||
where username ilike $1`,
|
||||
[username],
|
||||
(r) => r.count
|
||||
)
|
||||
@@ -71,7 +72,7 @@ export const createUser: APIHandler<'create-user'> = async (
|
||||
const isReservedName = RESERVED_PATHS.includes(username)
|
||||
if (usernameExists || isReservedName) username += randomString(4)
|
||||
|
||||
const { user, privateUser } = await pg.tx(async (tx) => {
|
||||
const {user, privateUser} = await pg.tx(async (tx) => {
|
||||
const preexistingUser = await getUser(auth.uid, tx)
|
||||
if (preexistingUser)
|
||||
throw new APIError(403, 'User already exists', {
|
||||
@@ -81,13 +82,13 @@ export const createUser: APIHandler<'create-user'> = async (
|
||||
// Check exact username to avoid problems with duplicate requests
|
||||
const sameNameUser = await getUserByUsername(username, tx)
|
||||
if (sameNameUser)
|
||||
throw new APIError(403, 'Username already taken', { username })
|
||||
throw new APIError(403, 'Username already taken', {username})
|
||||
|
||||
const user = removeUndefinedProps({
|
||||
avatarUrl,
|
||||
isBannedFromPosting: Boolean(
|
||||
(deviceToken && bannedDeviceTokens.includes(deviceToken)) ||
|
||||
(ip && bannedIpAddresses.includes(ip))
|
||||
(ip && bannedIpAddresses.includes(ip))
|
||||
),
|
||||
link: {},
|
||||
})
|
||||
@@ -120,10 +121,19 @@ export const createUser: APIHandler<'create-user'> = async (
|
||||
}
|
||||
})
|
||||
|
||||
log('created user ', { username: user.username, firebaseId: auth.uid })
|
||||
log('created user ', {username: user.username, firebaseId: auth.uid})
|
||||
|
||||
const continuation = async () => {
|
||||
await track(auth.uid, 'create profile', { username: user.username })
|
||||
try {
|
||||
await track(auth.uid, 'create profile', {username: user.username})
|
||||
} catch (e) {
|
||||
console.error('Failed to track create profile', e)
|
||||
}
|
||||
try {
|
||||
if (!IS_LOCAL) await sendWelcomeEmail(user, privateUser)
|
||||
} catch (e) {
|
||||
console.error('Failed to sendWelcomeEmail', e)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -135,12 +145,6 @@ export const createUser: APIHandler<'create-user'> = async (
|
||||
}
|
||||
}
|
||||
|
||||
function getStorageBucketId() {
|
||||
return isProd()
|
||||
? PROD_CONFIG.firebaseConfig.storageBucket
|
||||
: DEV_CONFIG.firebaseConfig.storageBucket
|
||||
}
|
||||
|
||||
// Automatically ban users with these device tokens or ip addresses.
|
||||
const bannedDeviceTokens = [
|
||||
'fa807d664415',
|
||||
|
||||
27
backend/api/src/create-vote.ts
Normal file
27
backend/api/src/create-vote.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { getUser } from 'shared/utils'
|
||||
import { APIHandler, APIError } from './helpers/endpoint'
|
||||
import { insert } from 'shared/supabase/utils'
|
||||
import { tryCatch } from 'common/util/try-catch'
|
||||
|
||||
export const createVote: APIHandler<
|
||||
'create-vote'
|
||||
> = async ({ title, description, isAnonymous }, auth) => {
|
||||
const creator = await getUser(auth.uid)
|
||||
if (!creator) throw new APIError(401, 'Your account was not found')
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const { data, error } = await tryCatch(
|
||||
insert(pg, 'votes', {
|
||||
creator_id: creator.id,
|
||||
title,
|
||||
description,
|
||||
is_anonymous: isAnonymous,
|
||||
})
|
||||
)
|
||||
|
||||
if (error) throw new APIError(401, 'Error creating question')
|
||||
|
||||
return { data }
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { getUser } from 'shared/utils'
|
||||
import { APIError, APIHandler } from './helpers/endpoint'
|
||||
import { updatePrivateUser, updateUser } from 'shared/supabase/users'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { FieldVal } from 'shared/supabase/utils'
|
||||
import {getUser} from 'shared/utils'
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import * as admin from "firebase-admin";
|
||||
import {deleteUserFiles} from "shared/firebase-utils";
|
||||
|
||||
export const deleteMe: APIHandler<'me/delete'> = async (body, auth) => {
|
||||
const { username } = body
|
||||
const {username} = body
|
||||
const user = await getUser(auth.uid)
|
||||
if (!user) {
|
||||
throw new APIError(401, 'Your account was not found')
|
||||
@@ -16,13 +16,29 @@ export const deleteMe: APIHandler<'me/delete'> = async (body, auth) => {
|
||||
`Incorrect username. You are logged in as ${user.username}. Are you sure you want to delete this account?`
|
||||
)
|
||||
}
|
||||
const userId = user.id
|
||||
if (!userId) {
|
||||
throw new APIError(400, 'Invalid user ID')
|
||||
}
|
||||
|
||||
// Remove user data from Supabase
|
||||
const pg = createSupabaseDirectClient()
|
||||
await updateUser(pg, auth.uid, {
|
||||
userDeleted: true,
|
||||
isBannedFromPosting: true,
|
||||
})
|
||||
await updatePrivateUser(pg, auth.uid, {
|
||||
email: FieldVal.delete(),
|
||||
})
|
||||
await pg.none('DELETE FROM users WHERE id = $1', [userId])
|
||||
await pg.none('DELETE FROM private_users WHERE id = $1', [userId])
|
||||
await pg.none('DELETE FROM profiles WHERE user_id = $1', [userId])
|
||||
await pg.none('DELETE FROM bookmarked_searches WHERE creator_id = $1', [userId])
|
||||
await pg.none('DELETE FROM love_compatibility_answers WHERE creator_id = $1', [userId])
|
||||
// May need to also delete from other tables in the future (such as messages, compatibility responses, etc.)
|
||||
|
||||
// Delete user files from Firebase Storage
|
||||
await deleteUserFiles(user.username)
|
||||
|
||||
// Remove user from Firebase Auth
|
||||
try {
|
||||
const auth = admin.auth()
|
||||
await auth.deleteUser(userId)
|
||||
console.debug(`Deleted user ${userId} from Firebase Auth and Supabase`)
|
||||
} catch (e) {
|
||||
console.error('Error deleting user from Firebase Auth:', e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,15 @@ import { type APIHandler } from 'api/helpers/endpoint'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { Row } from 'common/supabase/utils'
|
||||
|
||||
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) => {
|
||||
@@ -22,17 +31,18 @@ export const getCompatibilityQuestions: APIHandler<
|
||||
love_questions.answer_type = 'compatibility_multiple_choice'
|
||||
GROUP BY
|
||||
love_questions.id
|
||||
ORDER BY
|
||||
score DESC
|
||||
ORDER BY
|
||||
love_questions.importance_score
|
||||
`,
|
||||
[]
|
||||
)
|
||||
|
||||
if (false)
|
||||
console.log(
|
||||
'got questions',
|
||||
questions.map((q) => q.question + ' ' + q.score)
|
||||
)
|
||||
// const questions = shuffle(dbQuestions)
|
||||
|
||||
// console.debug(
|
||||
// 'got questions',
|
||||
// questions.map((q) => q.question + ' ' + q.score)
|
||||
// )
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
|
||||
18
backend/api/src/get-messages-count.ts
Normal file
18
backend/api/src/get-messages-count.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import {APIHandler} from './helpers/endpoint'
|
||||
import {createSupabaseDirectClient} from "shared/supabase/init";
|
||||
|
||||
export const getMessagesCount: APIHandler<'get-messages-count'> = async (_, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
const result = await pg.one(
|
||||
`
|
||||
SELECT COUNT(*) AS count
|
||||
FROM private_user_messages;
|
||||
`,
|
||||
[]
|
||||
);
|
||||
const count = Number(result.count);
|
||||
console.debug('private_user_messages count:', count);
|
||||
return {
|
||||
count: count,
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import {type APIHandler} from 'api/helpers/endpoint'
|
||||
import {convertRow} from 'shared/love/supabase'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {from, join, limit, orderBy, renderSql, select, where,} from 'shared/supabase/sql-builder'
|
||||
import {from, join, leftJoin, limit, orderBy, renderSql, select, where,} from 'shared/supabase/sql-builder'
|
||||
import {getCompatibleProfiles} from 'api/compatible-profiles'
|
||||
import {intersection} from 'lodash'
|
||||
import {MAX_INT, MIN_INT} from "common/constants";
|
||||
import {MAX_INT, MIN_BIO_LENGTH, MIN_INT} from "common/constants";
|
||||
|
||||
export type profileQueryType = {
|
||||
limit?: number | undefined,
|
||||
@@ -16,20 +16,27 @@ export type profileQueryType = {
|
||||
pref_age_min?: number | undefined,
|
||||
pref_age_max?: number | undefined,
|
||||
pref_relation_styles?: String[] | undefined,
|
||||
pref_romantic_styles?: String[] | undefined,
|
||||
wants_kids_strength?: number | undefined,
|
||||
has_kids?: number | undefined,
|
||||
is_smoker?: boolean | undefined,
|
||||
shortBio?: boolean | undefined,
|
||||
geodbCityIds?: String[] | undefined,
|
||||
lat?: number | undefined,
|
||||
lon?: number | undefined,
|
||||
radius?: number | undefined,
|
||||
compatibleWithUserId?: string | undefined,
|
||||
skipId?: string | undefined,
|
||||
orderBy?: string | undefined,
|
||||
lastModificationWithin?: string | undefined,
|
||||
}
|
||||
|
||||
const userActivityColumns = ['last_online_time']
|
||||
|
||||
|
||||
export const loadProfiles = async (props: profileQueryType) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
console.log(props)
|
||||
console.debug(props)
|
||||
const {
|
||||
limit: limitParam,
|
||||
after,
|
||||
@@ -39,16 +46,23 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
pref_age_min,
|
||||
pref_age_max,
|
||||
pref_relation_styles,
|
||||
pref_romantic_styles,
|
||||
wants_kids_strength,
|
||||
has_kids,
|
||||
is_smoker,
|
||||
shortBio,
|
||||
geodbCityIds,
|
||||
lat,
|
||||
lon,
|
||||
radius,
|
||||
compatibleWithUserId,
|
||||
orderBy: orderByParam = 'created_time',
|
||||
lastModificationWithin,
|
||||
skipId,
|
||||
} = props
|
||||
|
||||
const filterLocation = lat && lon && radius
|
||||
|
||||
const keywords = name ? name.split(",").map(q => q.trim()).filter(Boolean) : []
|
||||
// console.debug('keywords:', keywords)
|
||||
|
||||
@@ -69,6 +83,8 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
(!pref_age_max || (l.age ?? MIN_INT) <= pref_age_max) &&
|
||||
(!pref_relation_styles ||
|
||||
intersection(pref_relation_styles, l.pref_relation_styles).length) &&
|
||||
(!pref_romantic_styles ||
|
||||
intersection(pref_romantic_styles, l.pref_romantic_styles).length) &&
|
||||
(!wants_kids_strength ||
|
||||
wants_kids_strength == -1 ||
|
||||
(wants_kids_strength >= 2
|
||||
@@ -81,23 +97,34 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
(!is_smoker || l.is_smoker === is_smoker) &&
|
||||
(l.id.toString() != skipId) &&
|
||||
(!geodbCityIds ||
|
||||
(l.geodb_city_id && geodbCityIds.includes(l.geodb_city_id)))
|
||||
(l.geodb_city_id && geodbCityIds.includes(l.geodb_city_id))) &&
|
||||
(!filterLocation ||(
|
||||
l.city_latitude && l.city_longitude &&
|
||||
Math.abs(l.city_latitude - lat) < radius / 69.0 &&
|
||||
Math.abs(l.city_longitude - lon) < radius / (69.0 * Math.cos(lat * Math.PI / 180)) &&
|
||||
Math.pow(l.city_latitude - lat, 2) + Math.pow((l.city_longitude - lon) * Math.cos(lat * Math.PI / 180), 2) < Math.pow(radius / 69.0, 2)
|
||||
)) &&
|
||||
(shortBio || (l.bio_length ?? 0) >= MIN_BIO_LENGTH)
|
||||
)
|
||||
|
||||
const cursor = after
|
||||
? profiles.findIndex((l) => l.id.toString() === after) + 1
|
||||
: 0
|
||||
console.log(cursor)
|
||||
console.debug(cursor)
|
||||
|
||||
if (limitParam) return profiles.slice(cursor, cursor + limitParam)
|
||||
|
||||
return profiles
|
||||
}
|
||||
|
||||
const tablePrefix = userActivityColumns.includes(orderByParam) ? 'user_activity' : 'profiles'
|
||||
const userActivityJoin = 'user_activity on user_activity.user_id = profiles.user_id'
|
||||
|
||||
const query = renderSql(
|
||||
select('profiles.*, name, username, users.data as user'),
|
||||
select('profiles.*, name, username, users.data as user, user_activity.last_online_time'),
|
||||
from('profiles'),
|
||||
join('users on users.id = profiles.user_id'),
|
||||
leftJoin(userActivityJoin),
|
||||
where('looking_for_matches = true'),
|
||||
// where(`pinned_url is not null and pinned_url != ''`),
|
||||
where(
|
||||
@@ -106,7 +133,7 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
where(`data->>'userDeleted' != 'true' or data->>'userDeleted' is null`),
|
||||
|
||||
...keywords.map(word => where(
|
||||
`lower(users.name) ilike '%' || lower($(word)) || '%' or lower(bio::text) ilike '%' || lower($(word)) || '%'`,
|
||||
`lower(users.name) ilike '%' || lower($(word)) || '%' or lower(bio::text) ilike '%' || lower($(word)) || '%' or bio_tsv @@ phraseto_tsquery('english', $(word))`,
|
||||
{word}
|
||||
)),
|
||||
|
||||
@@ -124,7 +151,13 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
pref_relation_styles?.length &&
|
||||
where(
|
||||
`pref_relation_styles IS NULL OR pref_relation_styles = '{}' OR pref_relation_styles && $(pref_relation_styles)`,
|
||||
{ pref_relation_styles }
|
||||
{pref_relation_styles}
|
||||
),
|
||||
|
||||
pref_romantic_styles?.length &&
|
||||
where(
|
||||
`pref_romantic_styles IS NULL OR pref_romantic_styles = '{}' OR pref_romantic_styles && $(pref_romantic_styles)`,
|
||||
{pref_romantic_styles}
|
||||
),
|
||||
|
||||
!!wants_kids_strength &&
|
||||
@@ -144,21 +177,40 @@ export const loadProfiles = async (props: profileQueryType) => {
|
||||
geodbCityIds?.length &&
|
||||
where(`geodb_city_id = ANY($(geodbCityIds))`, {geodbCityIds}),
|
||||
|
||||
skipId && where(`user_id != $(skipId)`, {skipId}),
|
||||
// miles par degree of lat: earth's radius (3950 miles) * pi / 180 = 69.0
|
||||
filterLocation && where(`
|
||||
city_latitude BETWEEN $(target_lat) - ($(radius) / 69.0)
|
||||
AND $(target_lat) + ($(radius) / 69.0)
|
||||
AND city_longitude BETWEEN $(target_lon) - ($(radius) / (69.0 * COS(RADIANS($(target_lat)))))
|
||||
AND $(target_lon) + ($(radius) / (69.0 * COS(RADIANS($(target_lat)))))
|
||||
AND SQRT(
|
||||
POWER(city_latitude - $(target_lat), 2)
|
||||
+ POWER((city_longitude - $(target_lon)) * COS(RADIANS($(target_lat))), 2)
|
||||
) <= $(radius) / 69.0
|
||||
`, {target_lat: lat, target_lon: lon, radius}),
|
||||
|
||||
orderBy(`${orderByParam} desc`),
|
||||
skipId && where(`profiles.user_id != $(skipId)`, {skipId}),
|
||||
|
||||
orderBy(`${tablePrefix}.${orderByParam} DESC`),
|
||||
after &&
|
||||
where(
|
||||
`profiles.${orderByParam} < (select profiles.${orderByParam} from profiles where id = $(after))`,
|
||||
`${tablePrefix}.${orderByParam} < (
|
||||
SELECT ${tablePrefix}.${orderByParam}
|
||||
FROM profiles
|
||||
LEFT JOIN ${userActivityJoin}
|
||||
WHERE profiles.id = $(after)
|
||||
)`,
|
||||
{after}
|
||||
),
|
||||
|
||||
!shortBio && where(`bio_length >= ${MIN_BIO_LENGTH}`, {MIN_BIO_LENGTH}),
|
||||
|
||||
lastModificationWithin && where(`last_modification_time >= NOW() - INTERVAL $(lastModificationWithin)`, {lastModificationWithin}),
|
||||
|
||||
limitParam && limit(limitParam)
|
||||
)
|
||||
|
||||
// console.log('query:', query)
|
||||
// console.debug('query:', query)
|
||||
|
||||
return await pg.map(query, [], convertRow)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { sign } from 'jsonwebtoken'
|
||||
import { APIError, APIHandler } from './helpers/endpoint'
|
||||
import { DEV_CONFIG } from 'common/envs/dev'
|
||||
import { PROD_CONFIG } from 'common/envs/prod'
|
||||
import { isProd } from 'shared/utils'
|
||||
import {sign} from 'jsonwebtoken'
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {ENV_CONFIG} from "common/envs/constants";
|
||||
|
||||
export const getSupabaseToken: APIHandler<'get-supabase-token'> = async (
|
||||
_,
|
||||
@@ -12,21 +10,17 @@ export const getSupabaseToken: APIHandler<'get-supabase-token'> = async (
|
||||
if (jwtSecret == null) {
|
||||
throw new APIError(500, "No SUPABASE_JWT_SECRET; couldn't sign token.")
|
||||
}
|
||||
const instanceId = isProd()
|
||||
? PROD_CONFIG.supabaseInstanceId
|
||||
: DEV_CONFIG.supabaseInstanceId
|
||||
const instanceId = ENV_CONFIG.supabaseInstanceId
|
||||
if (!instanceId) {
|
||||
throw new APIError(500, 'No Supabase instance ID in config.')
|
||||
}
|
||||
const payload = { role: 'anon' } // postgres role
|
||||
const payload = {role: 'anon'} // postgres role
|
||||
return {
|
||||
jwt: sign(payload, jwtSecret, {
|
||||
algorithm: 'HS256', // same as what supabase uses for its auth tokens
|
||||
expiresIn: '1d',
|
||||
audience: instanceId,
|
||||
issuer: isProd()
|
||||
? PROD_CONFIG.firebaseConfig.projectId
|
||||
: DEV_CONFIG.firebaseConfig.projectId,
|
||||
issuer: ENV_CONFIG.firebaseConfig.projectId,
|
||||
subject: auth.uid,
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -1,35 +1,29 @@
|
||||
import * as admin from 'firebase-admin'
|
||||
import { z } from 'zod'
|
||||
import { Request, Response, NextFunction } from 'express'
|
||||
import {z} from 'zod'
|
||||
import {NextFunction, Request, Response} from 'express'
|
||||
|
||||
import { PrivateUser } from 'common/user'
|
||||
import { APIError } from 'common/api/utils'
|
||||
export { APIError } from 'common/api/utils'
|
||||
import {
|
||||
API,
|
||||
APIPath,
|
||||
APIResponseOptionalContinue,
|
||||
APISchema,
|
||||
ValidatedAPIParams,
|
||||
} from 'common/api/schema'
|
||||
import { log } from 'shared/utils'
|
||||
import { getPrivateUserByKey } from 'shared/utils'
|
||||
import {PrivateUser} from 'common/user'
|
||||
import {APIError} from 'common/api/utils'
|
||||
import {API, APIPath, APIResponseOptionalContinue, APISchema, ValidatedAPIParams,} from 'common/api/schema'
|
||||
import {getPrivateUserByKey, log} from 'shared/utils'
|
||||
|
||||
export type Json = Record<string, unknown> | Json[]
|
||||
export type JsonHandler<T extends Json> = (
|
||||
req: Request,
|
||||
res: Response
|
||||
) => Promise<T>
|
||||
export type AuthedHandler<T extends Json> = (
|
||||
req: Request,
|
||||
user: AuthedUser,
|
||||
res: Response
|
||||
) => Promise<T>
|
||||
export type MaybeAuthedHandler<T extends Json> = (
|
||||
req: Request,
|
||||
user: AuthedUser | undefined,
|
||||
res: Response
|
||||
) => Promise<T>
|
||||
export {APIError} from 'common/api/utils'
|
||||
|
||||
// export type Json = Record<string, unknown> | Json[]
|
||||
// export type JsonHandler<T extends Json> = (
|
||||
// req: Request,
|
||||
// res: Response
|
||||
// ) => Promise<T>
|
||||
// export type AuthedHandler<T extends Json> = (
|
||||
// req: Request,
|
||||
// user: AuthedUser,
|
||||
// res: Response
|
||||
// ) => Promise<T>
|
||||
// export type MaybeAuthedHandler<T extends Json> = (
|
||||
// req: Request,
|
||||
// user: AuthedUser | undefined,
|
||||
// res: Response
|
||||
// ) => Promise<T>
|
||||
|
||||
export type AuthedUser = {
|
||||
uid: string
|
||||
@@ -39,6 +33,29 @@ type JwtCredentials = { kind: 'jwt'; data: admin.auth.DecodedIdToken }
|
||||
type KeyCredentials = { kind: 'key'; data: string }
|
||||
type Credentials = JwtCredentials | KeyCredentials
|
||||
|
||||
// export async function verifyIdToken(payload: string): Promise<DecodedIdToken> {
|
||||
// TODO: make local dev work without firebase admin SDK setup.
|
||||
// if (IS_LOCAL) {
|
||||
// // Skip real verification locally (to avoid needing to set up admin service account).
|
||||
// return {
|
||||
// aud: "",
|
||||
// auth_time: 0,
|
||||
// email_verified: false,
|
||||
// exp: 0,
|
||||
// firebase: {identities: {}, sign_in_provider: ""},
|
||||
// iat: 0,
|
||||
// iss: "",
|
||||
// phone_number: "",
|
||||
// picture: "",
|
||||
// sub: "",
|
||||
// uid: 'dev-user',
|
||||
// user_id: 'dev-user',
|
||||
// email: 'dev-user@example.com'
|
||||
// };
|
||||
// }
|
||||
// return await admin.auth().verifyIdToken(payload);
|
||||
// }
|
||||
|
||||
export const parseCredentials = async (req: Request): Promise<Credentials> => {
|
||||
const auth = admin.auth()
|
||||
const authHeader = req.get('Authorization')
|
||||
@@ -57,14 +74,14 @@ export const parseCredentials = async (req: Request): Promise<Credentials> => {
|
||||
throw new APIError(401, 'Firebase JWT payload undefined.')
|
||||
}
|
||||
try {
|
||||
return { kind: 'jwt', data: await auth.verifyIdToken(payload) }
|
||||
return {kind: 'jwt', data: await auth.verifyIdToken(payload)}
|
||||
} catch (err) {
|
||||
// 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.')
|
||||
}
|
||||
case 'Key':
|
||||
return { kind: 'key', data: payload }
|
||||
return {kind: 'key', data: payload}
|
||||
default:
|
||||
throw new APIError(401, 'Invalid auth scheme; must be "Key" or "Bearer".')
|
||||
}
|
||||
@@ -76,7 +93,7 @@ export const lookupUser = async (creds: Credentials): Promise<AuthedUser> => {
|
||||
if (typeof creds.data.user_id !== 'string') {
|
||||
throw new APIError(401, 'JWT must contain user ID.')
|
||||
}
|
||||
return { uid: creds.data.user_id, creds }
|
||||
return {uid: creds.data.user_id, creds}
|
||||
}
|
||||
case 'key': {
|
||||
const key = creds.data
|
||||
@@ -84,7 +101,7 @@ export const lookupUser = async (creds: Credentials): Promise<AuthedUser> => {
|
||||
if (!privateUser) {
|
||||
throw new APIError(401, `No private user exists with API key ${key}.`)
|
||||
}
|
||||
return { uid: privateUser.id, creds: { privateUser, ...creds } }
|
||||
return {uid: privateUser.id, creds: {privateUser, ...creds}}
|
||||
}
|
||||
default:
|
||||
throw new APIError(401, 'Invalid credential type.')
|
||||
@@ -109,45 +126,45 @@ export const validate = <T extends z.ZodTypeAny>(schema: T, val: unknown) => {
|
||||
}
|
||||
}
|
||||
|
||||
export const jsonEndpoint = <T extends Json>(fn: JsonHandler<T>) => {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
res.status(200).json(await fn(req, res))
|
||||
} catch (e) {
|
||||
next(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const authEndpoint = <T extends Json>(fn: AuthedHandler<T>) => {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const authedUser = await lookupUser(await parseCredentials(req))
|
||||
res.status(200).json(await fn(req, authedUser, res))
|
||||
} catch (e) {
|
||||
next(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const MaybeAuthedEndpoint = <T extends Json>(
|
||||
fn: MaybeAuthedHandler<T>
|
||||
) => {
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
let authUser: AuthedUser | undefined = undefined
|
||||
try {
|
||||
authUser = await lookupUser(await parseCredentials(req))
|
||||
} catch {
|
||||
// it's treated as an anon request
|
||||
}
|
||||
|
||||
try {
|
||||
res.status(200).json(await fn(req, authUser, res))
|
||||
} catch (e) {
|
||||
next(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
// export const jsonEndpoint = <T extends Json>(fn: JsonHandler<T>) => {
|
||||
// return async (req: Request, res: Response, next: NextFunction) => {
|
||||
// try {
|
||||
// res.status(200).json(await fn(req, res))
|
||||
// } catch (e) {
|
||||
// next(e)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// export const authEndpoint = <T extends Json>(fn: AuthedHandler<T>) => {
|
||||
// return async (req: Request, res: Response, next: NextFunction) => {
|
||||
// try {
|
||||
// const authedUser = await lookupUser(await parseCredentials(req))
|
||||
// res.status(200).json(await fn(req, authedUser, res))
|
||||
// } catch (e) {
|
||||
// next(e)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// export const MaybeAuthedEndpoint = <T extends Json>(
|
||||
// fn: MaybeAuthedHandler<T>
|
||||
// ) => {
|
||||
// return async (req: Request, res: Response, next: NextFunction) => {
|
||||
// let authUser: AuthedUser | undefined = undefined
|
||||
// try {
|
||||
// authUser = await lookupUser(await parseCredentials(req))
|
||||
// } catch {
|
||||
// // it's treated as an anon request
|
||||
// }
|
||||
//
|
||||
// try {
|
||||
// res.status(200).json(await fn(req, authUser, res))
|
||||
// } catch (e) {
|
||||
// next(e)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
export type APIHandler<N extends APIPath> = (
|
||||
props: ValidatedAPIParams<N>,
|
||||
@@ -157,11 +174,63 @@ export type APIHandler<N extends APIPath> = (
|
||||
req: Request
|
||||
) => Promise<APIResponseOptionalContinue<N>>
|
||||
|
||||
// Simple in-memory fixed-window rate limiter keyed by auth uid (or IP if unauthenticated)
|
||||
// Not suitable for multi-instance deployments without a shared store, but provides basic protection.
|
||||
// Limits are configurable via env:
|
||||
// API_RATE_LIMIT_PER_MIN_AUTHED
|
||||
// API_RATE_LIMIT_PER_MIN_UNAUTHED
|
||||
// Endpoints can be exempted by adding their name to RATE_LIMIT_EXEMPT (comma-separated)
|
||||
const __rateLimitState: Map<string, { windowStart: number; count: number }> = new Map()
|
||||
|
||||
function getRateLimitConfig() {
|
||||
const authed = Number(process.env.API_RATE_LIMIT_PER_MIN_AUTHED ?? 120)
|
||||
const unAuthed = Number(process.env.API_RATE_LIMIT_PER_MIN_UNAUTHED ?? 120)
|
||||
return {authedLimit: authed, unAuthLimit: unAuthed}
|
||||
}
|
||||
|
||||
function rateLimitKey(name: string, req: Request, auth?: AuthedUser) {
|
||||
if (auth) return `uid:${auth.uid}`
|
||||
// fallback to IP for unauthenticated requests
|
||||
return `ip:${req.ip}`
|
||||
}
|
||||
|
||||
function checkRateLimit(name: string, req: Request, res: Response, auth?: AuthedUser) {
|
||||
const {authedLimit, unAuthLimit} = getRateLimitConfig()
|
||||
|
||||
const key = rateLimitKey(name, req, auth)
|
||||
const limit = auth ? authedLimit : unAuthLimit
|
||||
const now = Date.now()
|
||||
const windowMs = 60_000
|
||||
const windowStart = Math.floor(now / windowMs) * windowMs
|
||||
|
||||
let state = __rateLimitState.get(key)
|
||||
if (!state || state.windowStart !== windowStart) {
|
||||
state = {windowStart, count: 0}
|
||||
__rateLimitState.set(key, state)
|
||||
}
|
||||
state.count += 1
|
||||
|
||||
const remaining = Math.max(0, limit - state.count)
|
||||
const reset = Math.ceil((state.windowStart + windowMs - now) / 1000)
|
||||
|
||||
// Set standard-ish rate limit headers
|
||||
res.setHeader('X-RateLimit-Limit', String(limit))
|
||||
res.setHeader('X-RateLimit-Remaining', String(Math.max(0, remaining)))
|
||||
res.setHeader('X-RateLimit-Reset', String(reset))
|
||||
|
||||
// console.log(`Rate limit check for ${key} on ${name}: ${state.count}/${limit} (remaining: ${remaining}, resets in ${reset}s)`)
|
||||
|
||||
if (state.count > limit) {
|
||||
res.setHeader('Retry-After', String(reset))
|
||||
throw new APIError(429, 'Too Many Requests: rate limit exceeded.')
|
||||
}
|
||||
}
|
||||
|
||||
export const typedEndpoint = <N extends APIPath>(
|
||||
name: N,
|
||||
handler: APIHandler<N>
|
||||
) => {
|
||||
const { props: propSchema, authed: authRequired, method } = API[name]
|
||||
const {props: propSchema, authed: authRequired, rateLimited = false, method} = API[name] as APISchema<N>
|
||||
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
let authUser: AuthedUser | undefined = undefined
|
||||
@@ -171,6 +240,15 @@ export const typedEndpoint = <N extends APIPath>(
|
||||
if (authRequired) return next(e)
|
||||
}
|
||||
|
||||
// Apply rate limiting before invoking the handler
|
||||
if (rateLimited) {
|
||||
try {
|
||||
checkRateLimit(String(name), req, res, authUser)
|
||||
} catch (e) {
|
||||
return next(e)
|
||||
}
|
||||
}
|
||||
|
||||
const props = {
|
||||
...(method === 'GET' ? req.query : req.body),
|
||||
...req.params,
|
||||
@@ -195,7 +273,7 @@ export const typedEndpoint = <N extends APIPath>(
|
||||
// Convert bigint to number, b/c JSON doesn't support bigint.
|
||||
const convertedResult = deepConvertBigIntToNumber(result)
|
||||
|
||||
res.status(200).json(convertedResult ?? { success: true })
|
||||
res.status(200).json(convertedResult ?? {success: true})
|
||||
}
|
||||
|
||||
if (hasContinue) {
|
||||
|
||||
@@ -163,7 +163,7 @@ const notifyOtherUserInChannelIfInactive = async (
|
||||
// TODO: notification only for active user
|
||||
|
||||
const otherUser = await getUser(otherUserId.user_id)
|
||||
console.log('otherUser:', otherUser)
|
||||
console.debug('otherUser:', otherUser)
|
||||
if (!otherUser) return
|
||||
|
||||
await createNewMessageNotification(creator, otherUser, channelId)
|
||||
@@ -175,7 +175,7 @@ const createNewMessageNotification = async (
|
||||
channelId: number
|
||||
) => {
|
||||
const privateUser = await getPrivateUser(toUser.id)
|
||||
console.log('privateUser:', privateUser)
|
||||
console.debug('privateUser:', privateUser)
|
||||
if (!privateUser) return
|
||||
await sendNewMessageEmail(privateUser, fromUser, toUser, channelId)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { APIError, APIHandler } from './helpers/endpoint'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { tryCatch } from 'common/util/try-catch'
|
||||
import { insert } from 'shared/supabase/utils'
|
||||
import {APIError, APIHandler} from './helpers/endpoint'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
import {tryCatch} from 'common/util/try-catch'
|
||||
import {insert} from 'shared/supabase/utils'
|
||||
import {sendDiscordMessage} from "common/discord/core";
|
||||
import {Row} from "common/supabase/utils";
|
||||
import {DOMAIN} from "common/envs/constants";
|
||||
|
||||
// abusable: people can report the wrong person, that didn't write the comment
|
||||
// but in practice we check it manually and nothing bad happens to them automatically
|
||||
@@ -33,5 +36,38 @@ export const report: APIHandler<'report'> = async (body, auth) => {
|
||||
throw new APIError(500, 'Failed to create report: ' + result.error.message)
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
const continuation = async () => {
|
||||
try {
|
||||
const {data: reporter, error} = await tryCatch(
|
||||
pg.oneOrNone<Row<'users'>>('select * from users where id = $1', [auth.uid])
|
||||
)
|
||||
if (error) {
|
||||
console.error('Failed to get user for report', error)
|
||||
return
|
||||
}
|
||||
const {data: reported, error: userError} = await tryCatch(
|
||||
pg.oneOrNone<Row<'users'>>('select * from users where id = $1', [contentOwnerId])
|
||||
)
|
||||
if (userError) {
|
||||
console.error('Failed to get reported user for report', userError)
|
||||
return
|
||||
}
|
||||
let message: string = `
|
||||
🚨 **New Report** 🚨
|
||||
**Type:** ${contentType}
|
||||
**Content ID:** ${contentId}
|
||||
**Reporter:** ${reporter?.name} ([@${reporter?.username}](https://www.${DOMAIN}/${reporter?.username}))
|
||||
**Reported:** ${reported?.name} ([@${reported?.username}](https://www.${DOMAIN}/${reported?.username}))
|
||||
`
|
||||
await sendDiscordMessage(message, 'reports')
|
||||
} catch (e) {
|
||||
console.error('Failed to send discord reports', e)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {},
|
||||
continue: continuation,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,5 +4,6 @@ import {geodbFetch} from "common/geodb";
|
||||
export const searchLocation: APIHandler<'search-location'> = async (body) => {
|
||||
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`
|
||||
return await geodbFetch(endpoint)
|
||||
}
|
||||
|
||||
@@ -2,13 +2,12 @@ import {APIHandler} from './helpers/endpoint'
|
||||
import {geodbFetch} from "common/geodb";
|
||||
|
||||
const searchNearCityMain = async (cityId: string, radius: number) => {
|
||||
// Limit to 10 cities for now for free plan, was 100 before (may need to buy plan)
|
||||
const endpoint = `/cities/${cityId}/nearbyCities?radius=${radius}&offset=0&sort=-population&limit=10`
|
||||
const endpoint = `/cities/${cityId}/nearbyCities?radius=${radius}&offset=0&sort=-population&limit=100`
|
||||
return await geodbFetch(endpoint)
|
||||
}
|
||||
|
||||
export const searchNearCity: APIHandler<'search-near-city'> = async (body) => {
|
||||
const { cityId, radius } = body
|
||||
const {cityId, radius} = body
|
||||
return await searchNearCityMain(cityId, radius)
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export const sendSearchNotifications = async () => {
|
||||
from('bookmarked_searches'),
|
||||
)
|
||||
const searches = await pg.map(search_query, [], convertSearchRow) as Row<'bookmarked_searches'>[]
|
||||
console.log(`Running ${searches.length} bookmarked searches`)
|
||||
console.debug(`Running ${searches.length} bookmarked searches`)
|
||||
|
||||
const _users = await pg.map(
|
||||
renderSql(
|
||||
@@ -36,7 +36,7 @@ export const sendSearchNotifications = async () => {
|
||||
convertSearchRow
|
||||
) as Row<'users'>[]
|
||||
const users = keyBy(_users, 'id')
|
||||
console.log('users', users)
|
||||
console.debug('users', users)
|
||||
|
||||
const _privateUsers = await pg.map(
|
||||
renderSql(
|
||||
@@ -47,15 +47,21 @@ export const sendSearchNotifications = async () => {
|
||||
convertSearchRow
|
||||
) as Row<'private_users'>[]
|
||||
const privateUsers = keyBy(_privateUsers, 'id')
|
||||
console.log('privateUsers', privateUsers)
|
||||
console.debug('privateUsers', privateUsers)
|
||||
|
||||
const matches: MatchesByUserType = {}
|
||||
|
||||
for (const row of searches) {
|
||||
if (typeof row.search_filters !== 'object') continue;
|
||||
const props = {...row.search_filters, skipId: row.creator_id, lastModificationWithin: '24 hours'}
|
||||
const { orderBy, ...filters } = (row.search_filters ?? {}) as Record<string, any>
|
||||
const props = {
|
||||
...filters,
|
||||
skipId: row.creator_id,
|
||||
lastModificationWithin: '24 hours',
|
||||
shortBio: true,
|
||||
}
|
||||
const profiles = await loadProfiles(props as profileQueryType)
|
||||
console.log(profiles.map((item: any) => item.name))
|
||||
console.debug(profiles.map((item: any) => item.name))
|
||||
if (!profiles.length) continue
|
||||
if (!(row.creator_id in matches)) {
|
||||
if (!privateUsers[row.creator_id]) continue
|
||||
@@ -74,7 +80,7 @@ export const sendSearchNotifications = async () => {
|
||||
})),
|
||||
})
|
||||
}
|
||||
console.log('matches:', JSON.stringify(matches, null, 2))
|
||||
console.debug('matches:', JSON.stringify(matches, null, 2))
|
||||
await notifyBookmarkedSearch(matches)
|
||||
|
||||
return {status: 'success'}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import * as admin from 'firebase-admin'
|
||||
import {getLocalEnv, initAdmin} from 'shared/init-admin'
|
||||
import {loadSecretsToEnv, getServiceAccountCredentials} from 'common/secrets'
|
||||
import {initAdmin} from 'shared/init-admin'
|
||||
import {loadSecretsToEnv} from 'common/secrets'
|
||||
import {log} from 'shared/utils'
|
||||
import {LOCAL_DEV} from "common/envs/constants";
|
||||
import {IS_LOCAL} from "common/envs/constants";
|
||||
import {METRIC_WRITER} from 'shared/monitoring/metric-writer'
|
||||
import {listen as webSocketListen} from 'shared/websockets/server'
|
||||
|
||||
log('Api server starting up....')
|
||||
|
||||
if (LOCAL_DEV) {
|
||||
if (IS_LOCAL) {
|
||||
initAdmin()
|
||||
} else {
|
||||
const projectId = process.env.GOOGLE_CLOUD_PROJECT
|
||||
@@ -21,9 +21,10 @@ if (LOCAL_DEV) {
|
||||
METRIC_WRITER.start()
|
||||
|
||||
import {app} from './app'
|
||||
import {getServiceAccountCredentials} from "shared/firebase-utils";
|
||||
|
||||
const credentials = LOCAL_DEV
|
||||
? getServiceAccountCredentials(getLocalEnv())
|
||||
const credentials = IS_LOCAL
|
||||
? getServiceAccountCredentials()
|
||||
: // No explicit credentials needed for deployed service.
|
||||
undefined
|
||||
|
||||
@@ -37,6 +38,5 @@ const startupProcess = async () => {
|
||||
})
|
||||
|
||||
webSocketListen(httpServer, '/ws')
|
||||
log('Server started successfully')
|
||||
}
|
||||
startupProcess()
|
||||
startupProcess().then(r => log('Server started successfully'))
|
||||
|
||||
22
backend/api/src/set-last-online-time.ts
Normal file
22
backend/api/src/set-last-online-time.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import {APIHandler} from './helpers/endpoint'
|
||||
import {createSupabaseDirectClient} from 'shared/supabase/init'
|
||||
|
||||
export const setLastOnlineTime: APIHandler<'set-last-online-time'> = async (
|
||||
_,
|
||||
auth
|
||||
) => {
|
||||
if (!auth || !auth.uid) return
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
await pg.none(`
|
||||
INSERT INTO user_activity (user_id, last_online_time)
|
||||
VALUES ($1, now())
|
||||
ON CONFLICT (user_id)
|
||||
DO UPDATE
|
||||
SET last_online_time = EXCLUDED.last_online_time
|
||||
WHERE user_activity.last_online_time < now() - interval '1 minute';
|
||||
`,
|
||||
[auth.uid]
|
||||
)
|
||||
// console.log('setLastOnline')
|
||||
}
|
||||
@@ -24,8 +24,7 @@ export const updateProfile: APIHandler<'update-profile'> = async (
|
||||
throw new APIError(404, 'Profile not found')
|
||||
}
|
||||
|
||||
!parsedBody.last_online_time &&
|
||||
log('Updating profile', { userId: auth.uid, parsedBody })
|
||||
log('Updating profile', { userId: auth.uid, parsedBody })
|
||||
|
||||
await removePinnedUrlFromPhotoUrls(parsedBody)
|
||||
if (parsedBody.avatar_url) {
|
||||
|
||||
39
backend/api/src/vote.ts
Normal file
39
backend/api/src/vote.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { getUser } from 'shared/utils'
|
||||
import { APIHandler, APIError } from './helpers/endpoint'
|
||||
|
||||
export const vote: APIHandler<'vote'> = async ({ voteId, choice, priority }, auth) => {
|
||||
const user = await getUser(auth.uid)
|
||||
if (!user) throw new APIError(401, 'Your account was not found')
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
// Map string choice to smallint (-1, 0, 1)
|
||||
const choiceMap: Record<string, number> = {
|
||||
'for': 1,
|
||||
'abstain': 0,
|
||||
'against': -1,
|
||||
}
|
||||
const choiceVal = choiceMap[choice]
|
||||
if (choiceVal === undefined) {
|
||||
throw new APIError(400, 'Invalid choice')
|
||||
}
|
||||
|
||||
// Upsert the vote result to ensure one vote per user per vote
|
||||
// Assuming table vote_results with unique (user_id, vote_id)
|
||||
const query = `
|
||||
insert into vote_results (user_id, vote_id, choice, priority)
|
||||
values ($1, $2, $3, $4)
|
||||
on conflict (user_id, vote_id)
|
||||
do update set choice = excluded.choice,
|
||||
priority = excluded.priority
|
||||
returning *;
|
||||
`
|
||||
|
||||
try {
|
||||
const result = await pg.one(query, [user.id, voteId, choiceVal, priority])
|
||||
return { data: result }
|
||||
} catch (e) {
|
||||
throw new APIError(500, 'Error recording vote', e as any)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import {PrivateUser, User} from 'common/user'
|
||||
import {getNotificationDestinationsForUser} from 'common/user-notification-preferences'
|
||||
import {getNotificationDestinationsForUser, UNSUBSCRIBE_URL} from 'common/user-notification-preferences'
|
||||
import {sendEmail} from './send-email'
|
||||
import {NewMessageEmail} from '../new-message'
|
||||
import {NewEndorsementEmail} from '../new-endorsement'
|
||||
@@ -8,8 +8,9 @@ import {getProfile} from 'shared/love/supabase'
|
||||
import { render } from "@react-email/render"
|
||||
import {MatchesType} from "common/love/bookmarked_searches";
|
||||
import NewSearchAlertsEmail from "email/new-search_alerts";
|
||||
import WelcomeEmail from "email/welcome";
|
||||
|
||||
const from = 'Compass <no-reply@compassmeet.com>'
|
||||
const from = 'Compass <compass@compassmeet.com>'
|
||||
|
||||
// export const sendNewMatchEmail = async (
|
||||
// privateUser: PrivateUser,
|
||||
@@ -75,6 +76,25 @@ export const sendNewMessageEmail = async (
|
||||
})
|
||||
}
|
||||
|
||||
export const sendWelcomeEmail = async (
|
||||
toUser: User,
|
||||
privateUser: PrivateUser,
|
||||
) => {
|
||||
if (!privateUser.email) return
|
||||
return await sendEmail({
|
||||
from,
|
||||
subject: `Welcome to Compass!`,
|
||||
to: privateUser.email,
|
||||
html: await render(
|
||||
<WelcomeEmail
|
||||
toUser={toUser}
|
||||
unsubscribeUrl={UNSUBSCRIBE_URL}
|
||||
email={privateUser.email}
|
||||
/>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
export const sendSearchAlertsEmail = async (
|
||||
toUser: User,
|
||||
privateUser: PrivateUser,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ProfileRow } from 'common/love/profile'
|
||||
import type { User } from 'common/user'
|
||||
import {ProfileRow} from 'common/love/profile'
|
||||
import type {User} from 'common/user'
|
||||
|
||||
// for email template testing
|
||||
|
||||
export const sinclairUser: User = {
|
||||
export const mockUser: User = {
|
||||
createdTime: 0,
|
||||
bio: 'the futa in futarchy',
|
||||
website: 'sincl.ai',
|
||||
@@ -31,14 +31,14 @@ export const sinclairProfile: ProfileRow = {
|
||||
id: 55,
|
||||
user_id: '0k1suGSJKVUnHbCPEhHNpgZPkUP2',
|
||||
created_time: '2023-10-27T00:41:59.851776+00:00',
|
||||
last_online_time: '2024-05-17T02:11:48.83+00:00',
|
||||
last_modification_time: '2024-05-17T02:11:48.83+00:00',
|
||||
city: 'San Francisco',
|
||||
gender: 'trans-female',
|
||||
pref_gender: ['female', 'trans-female'],
|
||||
pref_age_min: 18,
|
||||
pref_age_max: 21,
|
||||
pref_relation_styles: ['poly', 'open', 'mono'],
|
||||
pref_relation_styles: ['friendship'],
|
||||
pref_romantic_styles: ['poly', 'open', 'mono'],
|
||||
wants_kids_strength: 3,
|
||||
looking_for_matches: true,
|
||||
visibility: 'public',
|
||||
@@ -78,6 +78,7 @@ export const sinclairProfile: ProfileRow = {
|
||||
city_longitude: -122.416389,
|
||||
geodb_city_id: '126964',
|
||||
referred_by_username: null,
|
||||
bio_length: 1000,
|
||||
bio: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
@@ -101,6 +102,8 @@ export const sinclairProfile: ProfileRow = {
|
||||
},
|
||||
],
|
||||
},
|
||||
bio_text: 'the futa in futarchy',
|
||||
bio_tsv: 'the futa in futarchy',
|
||||
age: 25,
|
||||
}
|
||||
|
||||
@@ -129,14 +132,14 @@ export const jamesProfile: ProfileRow = {
|
||||
id: 2,
|
||||
user_id: '5LZ4LgYuySdL1huCWe7bti02ghx2',
|
||||
created_time: '2023-10-21T21:18:26.691211+00:00',
|
||||
last_online_time: '2024-07-06T17:29:16.833+00:00',
|
||||
last_modification_time: '2024-05-17T02:11:48.83+00:00',
|
||||
city: 'San Francisco',
|
||||
gender: 'male',
|
||||
pref_gender: ['female'],
|
||||
pref_age_min: 22,
|
||||
pref_age_max: 32,
|
||||
pref_relation_styles: ['mono'],
|
||||
pref_relation_styles: ['friendship'],
|
||||
pref_romantic_styles: ['poly', 'open', 'mono'],
|
||||
wants_kids_strength: 4,
|
||||
looking_for_matches: true,
|
||||
visibility: 'public',
|
||||
@@ -173,6 +176,7 @@ export const jamesProfile: ProfileRow = {
|
||||
city_longitude: -122.416389,
|
||||
geodb_city_id: '126964',
|
||||
referred_by_username: null,
|
||||
bio_length: 1000,
|
||||
bio: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
@@ -202,5 +206,7 @@ export const jamesProfile: ProfileRow = {
|
||||
},
|
||||
],
|
||||
},
|
||||
bio_text: 'the futa in futarchy',
|
||||
bio_tsv: 'the futa in futarchy',
|
||||
age: 32,
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
type CreateEmailOptions,
|
||||
} from 'resend'
|
||||
import { log } from 'shared/utils'
|
||||
import {sleep} from "common/util/time";
|
||||
|
||||
|
||||
/*
|
||||
* typically: { subject: string, to: string | string[] } & ({ text: string } | { react: ReactNode })
|
||||
@@ -13,12 +15,15 @@ export const sendEmail = async (
|
||||
options?: CreateEmailRequestOptions
|
||||
) => {
|
||||
const resend = getResend()
|
||||
console.log(resend, payload, options)
|
||||
console.debug(resend, payload, options)
|
||||
|
||||
if (!resend) return null
|
||||
|
||||
const { data, error } = await resend.emails.send(
|
||||
{ replyTo: 'Compass <no-reply@compassmeet.com>', ...payload },
|
||||
{ replyTo: 'Compass <hello@compassmeet.com>', ...payload },
|
||||
options
|
||||
)
|
||||
console.log('resend.emails.send', data, error)
|
||||
console.debug('resend.emails.send', data, error)
|
||||
|
||||
if (error) {
|
||||
log.error(
|
||||
@@ -29,6 +34,9 @@ export const sendEmail = async (
|
||||
}
|
||||
|
||||
log(`Sent email to ${payload.to} with subject ${payload.subject}`)
|
||||
|
||||
await sleep(1000) // to avoid rate limits (2 / second in resend free plan)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
@@ -36,8 +44,13 @@ let resend: Resend | null = null
|
||||
const getResend = () => {
|
||||
if (resend) return resend
|
||||
|
||||
if (!process.env.RESEND_KEY) {
|
||||
console.debug('No RESEND_KEY, skipping email send')
|
||||
return
|
||||
}
|
||||
|
||||
const apiKey = process.env.RESEND_KEY as string
|
||||
// console.log(`RESEND_KEY: ${apiKey}`)
|
||||
// console.debug(`RESEND_KEY: ${apiKey}`)
|
||||
resend = new Resend(apiKey)
|
||||
return resend
|
||||
}
|
||||
|
||||
@@ -4,11 +4,11 @@ if (require.main === module) {
|
||||
const email = process.argv[2]
|
||||
if (!email) {
|
||||
console.error('Please provide an email address')
|
||||
console.log('Usage: ts-node send-test-email.ts your@email.com')
|
||||
console.debug('Usage: ts-node send-test-email.ts your@email.com')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
sendTestEmail(email)
|
||||
.then(() => console.log('Email sent successfully!'))
|
||||
.then(() => console.debug('Email sent successfully!'))
|
||||
.catch((error) => console.error('Failed to send email:', error))
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import {Body, Button, Column, Container, Head, Html, Preview, Row, Section, Text,} from '@react-email/components'
|
||||
import {type User} from 'common/user'
|
||||
import {DOMAIN} from 'common/envs/constants'
|
||||
import {jamesUser, sinclairUser} from './functions/mock'
|
||||
import {jamesUser, mockUser} from './functions/mock'
|
||||
import {button, container, content, Footer, main, paragraph} from "email/utils";
|
||||
|
||||
interface NewEndorsementEmailProps {
|
||||
@@ -74,7 +74,7 @@ export const NewEndorsementEmail = ({
|
||||
|
||||
NewEndorsementEmail.PreviewProps = {
|
||||
fromUser: jamesUser,
|
||||
onUser: sinclairUser,
|
||||
onUser: mockUser,
|
||||
endorsementText:
|
||||
"Sinclair is someone you want to have around because she injects creativity and humor into every conversation, and her laugh is infectious! Not to mention that she's a great employee, treats everyone with respect, and is even-tempered.",
|
||||
unsubscribeUrl: 'https://compassmeet.com/unsubscribe',
|
||||
|
||||
@@ -2,7 +2,7 @@ import {Body, Button, Container, Head, Html, Preview, Section, Text,} from '@rea
|
||||
import {DOMAIN} from 'common/envs/constants'
|
||||
import {type ProfileRow} from 'common/love/profile'
|
||||
import {type User} from 'common/user'
|
||||
import {jamesProfile, jamesUser, sinclairUser} from './functions/mock'
|
||||
import {jamesProfile, jamesUser, mockUser} from './functions/mock'
|
||||
import {Footer} from "email/utils";
|
||||
|
||||
interface NewMatchEmailProps {
|
||||
@@ -70,7 +70,7 @@ export const NewMatchEmail = ({
|
||||
}
|
||||
|
||||
NewMatchEmail.PreviewProps = {
|
||||
onUser: sinclairUser,
|
||||
onUser: mockUser,
|
||||
matchedWithUser: jamesUser,
|
||||
matchedProfile: jamesProfile,
|
||||
email: 'someone@gmail.com',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {Body, Button, Container, Head, Html, Preview, Section, Text,} from '@react-email/components'
|
||||
import {type User} from 'common/user'
|
||||
import {type ProfileRow} from 'common/love/profile'
|
||||
import {jamesProfile, jamesUser, sinclairUser,} from './functions/mock'
|
||||
import {jamesProfile, jamesUser, mockUser,} from './functions/mock'
|
||||
import {DOMAIN} from 'common/envs/constants'
|
||||
import {button, container, content, Footer, imageContainer, main, paragraph} from "email/utils";
|
||||
|
||||
@@ -74,7 +74,7 @@ export const NewMessageEmail = ({
|
||||
NewMessageEmail.PreviewProps = {
|
||||
fromUser: jamesUser,
|
||||
fromUserProfile: jamesProfile,
|
||||
toUser: sinclairUser,
|
||||
toUser: mockUser,
|
||||
channelId: 1,
|
||||
email: 'someone@gmail.com',
|
||||
unsubscribeUrl: 'https://compassmeet.com/unsubscribe',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user