mirror of
https://github.com/CompassConnections/Compass.git
synced 2025-12-23 22:18:43 -05:00
Pull up features from manifold.love
This commit is contained in:
@@ -11,7 +11,7 @@ NEXTAUTH_URL=http://localhost:3000
|
||||
# Email configuration
|
||||
EMAIL_SERVER_HOST=smtp.resend.dev
|
||||
EMAIL_SERVER_PORT=587
|
||||
EMAIL_SERVER_USER=BayesBond
|
||||
EMAIL_SERVER_USER=Compass
|
||||
EMAIL_SERVER_PASSWORD=
|
||||
RESEND_API_KEY=
|
||||
EMAIL_FROM=
|
||||
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -33,6 +33,9 @@ yarn-error.log*
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env
|
||||
.env.local
|
||||
.env.*
|
||||
.envrc
|
||||
supabase/*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
@@ -41,6 +44,13 @@ yarn-error.log*
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
.idea/
|
||||
node_modules
|
||||
yarn-error.log
|
||||
dev
|
||||
firebase-debug.log
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
*.db
|
||||
|
||||
*prisma/migrations
|
||||
@@ -48,3 +58,5 @@ martin
|
||||
.obsidian
|
||||
.idea
|
||||
*.last-run.json
|
||||
|
||||
*lock.hcl
|
||||
24
.prettierrc
Normal file
24
.prettierrc
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": false,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"plugins": ["prettier-plugin-sql"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.sql",
|
||||
"options": {
|
||||
"language": "postgresql",
|
||||
"keywordCase": "lower",
|
||||
"logicalOperatorNewline": "before"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": "*.svg",
|
||||
"options": {
|
||||
"parser": "html"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -13,13 +13,13 @@ We welcome pull requests, but only if they meet the project's quality and design
|
||||
1. **Fork the repository** using the GitHub UI.
|
||||
2. **Clone your fork** locally:
|
||||
```bash
|
||||
git clone https://github.com/your-username/BayesBond.git
|
||||
git clone https://github.com/your-username/Compass.git
|
||||
cd your-fork
|
||||
|
||||
3. **Add the upstream remote**:
|
||||
|
||||
```bash
|
||||
git remote add upstream https://github.com/BayesBond/BayesBond.git
|
||||
git remote add upstream https://github.com/CompassMeet/Compass.git
|
||||
```
|
||||
|
||||
## Create a New Branch
|
||||
|
||||
22
LICENSE-MIT
Normal file
22
LICENSE-MIT
Normal file
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 polylove, LLC
|
||||
Modified work Copyright (c) 2025 Compass
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
14
README.md
14
README.md
@@ -1,11 +1,11 @@
|
||||
|
||||
[](https://github.com/BayesBond/BayesBond/actions/workflows/ci.yml)
|
||||
[](https://github.com/BayesBond/BayesBond/actions/workflows/cd.yml)
|
||||
[](https://github.com/CompassMeet/Compass/actions/workflows/ci.yml)
|
||||
[](https://github.com/CompassMeet/Compass/actions/workflows/cd.yml)
|
||||

|
||||
|
||||
# Compass
|
||||
|
||||
This repository provides the source code for [Compass](https://bayesbond.vercel.app), a web application where rational thinkers can bond and form deep 1-1
|
||||
This repository provides the source code for [Compass](https://compassmeet.com), a web application where rational thinkers can bond and form deep 1-1
|
||||
relationships in a fully transparent and efficient way. It just got released—please share it with anyone who would benefit from it!
|
||||
|
||||
To contribute, please submit a pull request or issue, or fill out this [form](https://forms.gle/tKnXUMAbEreMK6FC6) for suggestions and collaborations.
|
||||
@@ -30,7 +30,7 @@ The full description is available [here](https://martinbraquet.com/meeting-ratio
|
||||
- [x] Set up page listing all the profiles
|
||||
- [x] Search through all the profile variables
|
||||
- [ ] (Set up chat / direct messaging)
|
||||
- [ ] Set up domain name (https://bayesbond.com)
|
||||
- [x] Set up domain name (https://compassmeet.com)
|
||||
|
||||
#### Secondary To Do
|
||||
|
||||
@@ -59,13 +59,13 @@ Below are all the steps to contribute. If you have any trouble or questions, ple
|
||||
|
||||
Clone the repo and navigating into it:
|
||||
```bash
|
||||
git clone git@github.com:BayesBond/BayesBond.git
|
||||
cd BayesBond
|
||||
git clone git@github.com:CompassMeet/Compass.git
|
||||
cd Compass
|
||||
```
|
||||
|
||||
Install the dependencies:
|
||||
```
|
||||
npm install
|
||||
npm install @tiptap/core@2.3.2 @tiptap/starter-kit@2.3.2 @tiptap/extension-link@2.3.2 @tiptap/extension-image@2.3.2 @tiptap/extension-blockquote@2.3.2 @tiptap/extension-bold@2.3.2 @tiptap/extension-mention@2.3.2 @tiptap/extension-floating-menu@2.3.2 @tiptap/extension-bubble-menu@2.3.2 @tiptap/suggestion@2.3.2 @tiptap/html@2.3.2 --save-exact
|
||||
```
|
||||
|
||||
### Environment Variables
|
||||
|
||||
7
backend/.gitignore
vendored
Normal file
7
backend/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
# Supabase
|
||||
.branches
|
||||
.temp
|
||||
.env
|
||||
|
||||
# regen-schema
|
||||
**/dump.sql
|
||||
50
backend/api/.eslintrc.js
Normal file
50
backend/api/.eslintrc.js
Normal file
@@ -0,0 +1,50 @@
|
||||
module.exports = {
|
||||
plugins: ['lodash', 'unused-imports'],
|
||||
extends: ['eslint:recommended'],
|
||||
ignorePatterns: ['dist', 'lib'],
|
||||
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'],
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/ban-types': [
|
||||
'error',
|
||||
{
|
||||
extendDefaults: true,
|
||||
types: {
|
||||
'{}': false,
|
||||
},
|
||||
},
|
||||
],
|
||||
'@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'],
|
||||
},
|
||||
}
|
||||
6
backend/api/.gcloudignore
Normal file
6
backend/api/.gcloudignore
Normal file
@@ -0,0 +1,6 @@
|
||||
.gitignore
|
||||
.gcloudignore
|
||||
/tsconfig.json
|
||||
/deploy.sh
|
||||
/src/
|
||||
/lib/
|
||||
16
backend/api/.gitignore
vendored
Normal file
16
backend/api/.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
# Compiled JavaScript files
|
||||
lib/
|
||||
dist/
|
||||
|
||||
# Node.js dependency directory
|
||||
node_modules/
|
||||
|
||||
# Terraform
|
||||
.terraform/*
|
||||
*.tfstate
|
||||
*.tfstate.*
|
||||
crash.log
|
||||
*_override.tf
|
||||
*_override.tf.json
|
||||
.terraformrc
|
||||
terraform.rc
|
||||
26
backend/api/Dockerfile
Normal file
26
backend/api/Dockerfile
Normal file
@@ -0,0 +1,26 @@
|
||||
# prereq: first do `yarn build` to compile typescript & etc.
|
||||
|
||||
FROM node:19-alpine
|
||||
WORKDIR /usr/src/app
|
||||
|
||||
# Install PM2 globally
|
||||
RUN yarn global add pm2
|
||||
|
||||
# first get dependencies in for efficient docker layering
|
||||
COPY dist/package.json dist/yarn.lock ./
|
||||
RUN yarn install --frozen-lockfile --production
|
||||
|
||||
# then copy over typescript payload
|
||||
COPY dist ./
|
||||
|
||||
# Copy the PM2 ecosystem configuration
|
||||
COPY ecosystem.config.js ./
|
||||
|
||||
ENV PORT=80
|
||||
EXPOSE 80/tcp
|
||||
# EXPOSE 8090/tcp
|
||||
# EXPOSE 8091/tcp
|
||||
# EXPOSE 8092/tcp
|
||||
|
||||
# Use PM2 to run the application with the ecosystem config
|
||||
CMD ["pm2-runtime", "ecosystem.config.js"]
|
||||
25
backend/api/README.md
Normal file
25
backend/api/README.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# api
|
||||
|
||||
One function to rule them all, one docker image to bind them
|
||||
|
||||
## Setup
|
||||
|
||||
You must have set up the `gcloud` cli
|
||||
|
||||
## Test
|
||||
|
||||
In root directory `./dev.sh [dev|prod]` will run the api with hot reload, along with all the other backend and web code.
|
||||
|
||||
## Deploy
|
||||
|
||||
Run `./deploy-api.sh [dev|prod]` in this directory
|
||||
|
||||
## Secrets management
|
||||
|
||||
Secrets are strings that shouldn't be checked into Git (eg API keys, passwords).
|
||||
|
||||
Add or remove keys using [Google Secret Manager](https://console.cloud.google.com/security/secret-manager), which provides them as environment variables to functions that require them.
|
||||
|
||||
[Secrets manager](https://console.cloud.google.com/security/secret-manager?project=polylove)
|
||||
|
||||
Secondly, please update the list of secret keys at `backend/shared/src/secrets.ts`. Only these keys are provided to functions, scripts, and the api.
|
||||
37
backend/api/debug.sh
Executable file
37
backend/api/debug.sh
Executable file
@@ -0,0 +1,37 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script to make it easy to tunnel into the currently running API instance on GCP
|
||||
# so that you can debug the Node process, e.g. to set breakpoints (in dev!!), use the REPL,
|
||||
# or do performance or memory profiling.
|
||||
|
||||
set -e
|
||||
|
||||
if [ -z "$1" ]; then
|
||||
echo "Usage: the first argument should be 'dev' or 'prod'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SERVICE_NAME="api"
|
||||
SERVICE_GROUP="${SERVICE_NAME}-group"
|
||||
ZONE="us-west1-b"
|
||||
ENV=${1:-dev}
|
||||
|
||||
case $ENV in
|
||||
dev)
|
||||
GCLOUD_PROJECT=polylove ;;
|
||||
prod)
|
||||
GCLOUD_PROJECT=polylove ;;
|
||||
*)
|
||||
echo "Invalid environment; must be dev or prod."
|
||||
exit 1
|
||||
esac
|
||||
|
||||
echo "Looking for API instance on ${GCLOUD_PROJECT} to talk to..."
|
||||
INSTANCE_ID=$(gcloud compute instance-groups list-instances ${SERVICE_GROUP} --format="value(NAME)" --zone=${ZONE} --project=${GCLOUD_PROJECT})
|
||||
|
||||
echo "Forwarding debugging port 9229 to ${INSTANCE_ID}. Open chrome://inspect in Chrome to connect."
|
||||
gcloud compute ssh ${INSTANCE_ID} \
|
||||
--project=${GCLOUD_PROJECT} \
|
||||
--zone=${ZONE} \
|
||||
-- \
|
||||
-NL 9229:localhost:9229
|
||||
40
backend/api/deploy-api.sh
Executable file
40
backend/api/deploy-api.sh
Executable file
@@ -0,0 +1,40 @@
|
||||
#!/bin/bash
|
||||
|
||||
# steps to deploy new version to GCP:
|
||||
# 1. build new docker image & upload to Google
|
||||
# 2. create a new GCP instance template with the new docker image
|
||||
# 3. tell the GCP 'backend service' for the API to update to the new template
|
||||
# 4. a. GCP creates a new instance with the new template
|
||||
# b. wait for the new instance to be healthy (serving TCP connections)
|
||||
# c. route new connections to the new instance
|
||||
# d. delete the old instance
|
||||
|
||||
set -e
|
||||
|
||||
if [[ ! "$1" =~ ^(dev|prod)$ ]]; then
|
||||
echo "Usage: $0 [dev|prod]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Config
|
||||
ENV=$1
|
||||
REGION="us-west1"
|
||||
ZONE="us-west1-b"
|
||||
PROJECT="polylove"
|
||||
SERVICE_NAME="api"
|
||||
|
||||
GIT_REVISION=$(git rev-parse --short HEAD)
|
||||
TIMESTAMP=$(date +"%s")
|
||||
IMAGE_TAG="${TIMESTAMP}-${GIT_REVISION}"
|
||||
IMAGE_URL="${REGION}-docker.pkg.dev/${PROJECT}/builds/${SERVICE_NAME}:${IMAGE_TAG}"
|
||||
|
||||
echo "🚀 Deploying ${SERVICE_NAME} to ${ENV} ($(date "+%Y-%m-%d %I:%M:%S %p"))"
|
||||
yarn build
|
||||
docker build . --tag ${IMAGE_URL} --platform linux/amd64
|
||||
docker push ${IMAGE_URL}
|
||||
|
||||
export TF_VAR_image_url=$IMAGE_URL
|
||||
export TF_VAR_env=$ENV
|
||||
tofu apply -auto-approve
|
||||
|
||||
echo "✅ Deployment complete! Image: ${IMAGE_URL}"
|
||||
18
backend/api/ecosystem.config.js
Normal file
18
backend/api/ecosystem.config.js
Normal file
@@ -0,0 +1,18 @@
|
||||
module.exports = {
|
||||
apps: [
|
||||
{
|
||||
name: 'serve',
|
||||
script: 'backend/api/src/serve.ts',
|
||||
instances: 1,
|
||||
exec_mode: 'fork',
|
||||
autorestart: true,
|
||||
watch: false,
|
||||
// 4 GB on the box, give 3 GB to the JS heap
|
||||
node_args: '--max-old-space-size=3072',
|
||||
max_memory_restart: '3500M', // 3.5 GB
|
||||
env: {
|
||||
PORT: 80,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
305
backend/api/main.tf
Normal file
305
backend/api/main.tf
Normal file
@@ -0,0 +1,305 @@
|
||||
# written by claude 3.7 lol
|
||||
|
||||
variable "image_url" {
|
||||
description = "Docker image URL"
|
||||
type = string
|
||||
default = "us-west1-docker.pkg.dev/polylove/builds/api:latest"
|
||||
}
|
||||
|
||||
variable "env" {
|
||||
description = "Environment (env or prod)"
|
||||
type = string
|
||||
default = "prod"
|
||||
}
|
||||
|
||||
locals {
|
||||
project = "polylove"
|
||||
region = "us-west1"
|
||||
zone = "us-west1-b"
|
||||
service_name = "api"
|
||||
machine_type = "e2-small"
|
||||
}
|
||||
|
||||
terraform {
|
||||
backend "gcs" {
|
||||
bucket = "polylove-terraform-state"
|
||||
prefix = "api"
|
||||
}
|
||||
}
|
||||
|
||||
provider "google" {
|
||||
project = local.project
|
||||
region = local.region
|
||||
zone = local.zone
|
||||
}
|
||||
|
||||
# Firebase Storage Buckets
|
||||
# Note you still have to deploy the rules: `firebase deploy --only storage`
|
||||
resource "google_storage_bucket" "public_storage" {
|
||||
name = "polylove.firebasestorage.app"
|
||||
location = "US-WEST1"
|
||||
force_destroy = false
|
||||
|
||||
uniform_bucket_level_access = true
|
||||
|
||||
cors {
|
||||
origin = ["*"]
|
||||
method = ["GET", "HEAD", "PUT", "POST", "DELETE"]
|
||||
response_header = ["*"]
|
||||
max_age_seconds = 3600
|
||||
}
|
||||
}
|
||||
|
||||
# static IPs
|
||||
resource "google_compute_global_address" "api_lb_ip" {
|
||||
name = "api-lb-ip-2"
|
||||
address_type = "EXTERNAL"
|
||||
}
|
||||
|
||||
resource "google_compute_managed_ssl_certificate" "api_cert" {
|
||||
name = "api-lb-cert-2"
|
||||
|
||||
managed {
|
||||
domains = ["api.poly.love"]
|
||||
}
|
||||
}
|
||||
|
||||
# Instance template with your Docker container
|
||||
resource "google_compute_instance_template" "api_template" {
|
||||
name_prefix = "${local.service_name}-"
|
||||
machine_type = local.machine_type
|
||||
|
||||
tags = ["lb-health-check"]
|
||||
|
||||
disk {
|
||||
source_image = "cos-cloud/cos-stable" # Container-Optimized OS
|
||||
auto_delete = true
|
||||
boot = true
|
||||
}
|
||||
|
||||
network_interface {
|
||||
network = "default"
|
||||
subnetwork = "default"
|
||||
access_config {
|
||||
network_tier = "PREMIUM"
|
||||
}
|
||||
}
|
||||
|
||||
service_account {
|
||||
scopes = ["cloud-platform"]
|
||||
}
|
||||
|
||||
metadata = {
|
||||
gce-container-declaration = <<EOF
|
||||
spec:
|
||||
containers:
|
||||
- image: '${var.image_url}'
|
||||
env:
|
||||
- name: NEXT_PUBLIC_FIREBASE_ENV
|
||||
value: ${upper(var.env)}
|
||||
- name: GOOGLE_CLOUD_PROJECT
|
||||
value: ${local.project}
|
||||
ports:
|
||||
- containerPort: 80
|
||||
EOF
|
||||
}
|
||||
|
||||
lifecycle {
|
||||
create_before_destroy = true
|
||||
}
|
||||
}
|
||||
|
||||
# Managed instance group (for 1 VM)
|
||||
resource "google_compute_region_instance_group_manager" "api_group" {
|
||||
name = "${local.service_name}-group"
|
||||
base_instance_name = "${local.service_name}-group"
|
||||
region = local.region
|
||||
target_size = 1
|
||||
|
||||
version {
|
||||
instance_template = google_compute_instance_template.api_template.id
|
||||
name = "primary"
|
||||
}
|
||||
|
||||
update_policy {
|
||||
type = "PROACTIVE"
|
||||
minimal_action = "REPLACE"
|
||||
max_unavailable_fixed = 0
|
||||
max_surge_fixed = 3
|
||||
}
|
||||
|
||||
named_port {
|
||||
name = "http"
|
||||
port = 80
|
||||
}
|
||||
|
||||
auto_healing_policies {
|
||||
health_check = google_compute_health_check.api_health_check.id
|
||||
initial_delay_sec = 300
|
||||
}
|
||||
}
|
||||
|
||||
resource "google_compute_health_check" "api_health_check" {
|
||||
name = "${local.service_name}-health-check"
|
||||
check_interval_sec = 5
|
||||
timeout_sec = 5
|
||||
healthy_threshold = 2
|
||||
unhealthy_threshold = 10
|
||||
|
||||
tcp_health_check {
|
||||
port = "80"
|
||||
}
|
||||
}
|
||||
|
||||
# Backend service
|
||||
resource "google_compute_backend_service" "api_backend" {
|
||||
name = "${local.service_name}-backend"
|
||||
protocol = "HTTP"
|
||||
port_name = "http"
|
||||
timeout_sec = 30
|
||||
|
||||
health_checks = [google_compute_health_check.api_health_check.id]
|
||||
|
||||
backend {
|
||||
group = google_compute_region_instance_group_manager.api_group.instance_group
|
||||
}
|
||||
|
||||
log_config {
|
||||
enable = true
|
||||
}
|
||||
}
|
||||
|
||||
# URL map
|
||||
resource "google_compute_url_map" "api_url_map" {
|
||||
name = "${local.service_name}-url-map"
|
||||
default_service = google_compute_backend_service.api_backend.id
|
||||
|
||||
host_rule {
|
||||
hosts = ["*"]
|
||||
path_matcher = "allpaths"
|
||||
}
|
||||
|
||||
path_matcher {
|
||||
name = "allpaths"
|
||||
default_service = google_compute_backend_service.api_backend.self_link
|
||||
}
|
||||
}
|
||||
|
||||
# HTTPS proxy
|
||||
resource "google_compute_target_https_proxy" "api_https_proxy" {
|
||||
name = "${local.service_name}-https-proxy"
|
||||
url_map = google_compute_url_map.api_url_map.id
|
||||
ssl_certificates = [google_compute_managed_ssl_certificate.api_cert.id]
|
||||
}
|
||||
|
||||
# Global forwarding rule (load balancer frontend)
|
||||
resource "google_compute_global_forwarding_rule" "api_https_forwarding_rule" {
|
||||
name = "${local.service_name}-https-forwarding-rule-2"
|
||||
target = google_compute_target_https_proxy.api_https_proxy.id
|
||||
port_range = "443"
|
||||
ip_address = google_compute_global_address.api_lb_ip.id
|
||||
}
|
||||
|
||||
# HTTP-to-HTTPS redirect
|
||||
resource "google_compute_url_map" "api_http_redirect" {
|
||||
name = "${local.service_name}-http-redirect"
|
||||
|
||||
default_url_redirect {
|
||||
https_redirect = true
|
||||
redirect_response_code = "MOVED_PERMANENTLY_DEFAULT"
|
||||
strip_query = false
|
||||
}
|
||||
}
|
||||
|
||||
resource "google_compute_target_http_proxy" "api_http_proxy" {
|
||||
name = "${local.service_name}-http-proxy"
|
||||
url_map = google_compute_url_map.api_http_redirect.id
|
||||
}
|
||||
|
||||
resource "google_compute_global_forwarding_rule" "api_http_forwarding_rule" {
|
||||
name = "${local.service_name}-http-forwarding-rule"
|
||||
target = google_compute_target_http_proxy.api_http_proxy.id
|
||||
port_range = "80"
|
||||
ip_address = google_compute_global_address.api_lb_ip.id
|
||||
}
|
||||
|
||||
|
||||
# Firewalls
|
||||
|
||||
resource "google_compute_firewall" "allow_health_check" {
|
||||
name = "allow-health-check-${local.service_name}"
|
||||
network = "default"
|
||||
|
||||
allow {
|
||||
protocol = "tcp"
|
||||
ports = ["80"]
|
||||
}
|
||||
|
||||
source_ranges = ["130.211.0.0/22", "35.191.0.0/16"]
|
||||
target_tags = ["lb-health-check"]
|
||||
}
|
||||
|
||||
resource "google_compute_firewall" "default_allow_https" {
|
||||
name = "default-allow-http"
|
||||
network = "default"
|
||||
priority = 1000
|
||||
direction = "INGRESS"
|
||||
|
||||
allow {
|
||||
protocol = "tcp"
|
||||
ports = ["80", "443"] # ["443", "8090-8099"]
|
||||
}
|
||||
|
||||
source_ranges = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
resource "google_compute_firewall" "default_allow_ssh" {
|
||||
name = "default-allow-ssh"
|
||||
network = "default"
|
||||
priority = 65534
|
||||
direction = "INGRESS"
|
||||
|
||||
allow {
|
||||
protocol = "tcp"
|
||||
ports = ["22"]
|
||||
}
|
||||
|
||||
source_ranges = ["0.0.0.0/0"]
|
||||
}
|
||||
|
||||
resource "google_compute_firewall" "default_allow_internal" {
|
||||
name = "default-allow-internal"
|
||||
network = "default"
|
||||
priority = 65534
|
||||
direction = "INGRESS"
|
||||
|
||||
allow {
|
||||
protocol = "tcp"
|
||||
ports = ["0-65535"]
|
||||
}
|
||||
|
||||
allow {
|
||||
protocol = "udp"
|
||||
ports = ["0-65535"]
|
||||
}
|
||||
|
||||
allow {
|
||||
protocol = "icmp"
|
||||
}
|
||||
|
||||
source_ranges = ["10.128.0.0/9"]
|
||||
}
|
||||
|
||||
# Allow ICMP (ping)
|
||||
resource "google_compute_firewall" "default_allow_icmp" {
|
||||
name = "default-allow-icmp"
|
||||
network = "default"
|
||||
priority = 65534
|
||||
direction = "INGRESS"
|
||||
|
||||
allow {
|
||||
protocol = "icmp"
|
||||
}
|
||||
|
||||
source_ranges = ["0.0.0.0/0"]
|
||||
}
|
||||
67
backend/api/package.json
Normal file
67
backend/api/package.json
Normal file
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"name": "@compass/api",
|
||||
"description": "Backend API endpoints for the Manifold Love website.",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"watch:compile": "npx concurrently \"tsc -b --watch --preserveWatchOutput\" \"(cd ../../common && tsc-alias --watch)\" \"(cd ../shared && tsc-alias --watch)\" \"(cd ../email && tsc-alias --watch)\" \"tsc-alias --watch\"",
|
||||
"watch:serve": "nodemon -r tsconfig-paths/register --watch lib --ignore 'lib/**/*.map' src/serve.ts",
|
||||
"dev": "npx concurrently -n COMPILE,SERVER -c cyan,green \"yarn watch:compile\" \"yarn watch:serve\"",
|
||||
"build": "yarn compile && yarn dist:clean && yarn dist:copy",
|
||||
"build:fast": "yarn compile && yarn dist:copy",
|
||||
"compile": "tsc -b && (cd ../../common && tsc-alias) && (cd ../shared && tsc-alias) && (cd ../email && tsc-alias)",
|
||||
"debug": "nodemon -r tsconfig-paths/register --watch src -e ts --watch ../../common/src --watch ../shared/src --exec \"yarn build && node --inspect-brk src/serve.ts\"",
|
||||
"dist": "yarn dist:clean && yarn dist:copy",
|
||||
"dist:clean": "rm -rf dist && mkdir -p dist/common/lib dist/backend/shared/lib dist/backend/api/lib dist/backend/email/lib",
|
||||
"dist:copy": "rsync -a --delete ../../common/lib/ dist/common/lib && rsync -a --delete ../shared/lib/ dist/backend/shared/lib && rsync -a --delete ../email/lib/ dist/backend/email/lib && rsync -a --delete ./lib/* dist/backend/api/lib && cp ../../yarn.lock dist && cp package.json dist",
|
||||
"watch": "tsc -w",
|
||||
"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"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.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",
|
||||
"cors": "2.8.5",
|
||||
"dayjs": "1.11.4",
|
||||
"express": "4.18.1",
|
||||
"firebase-admin": "11.11.1",
|
||||
"gcp-metadata": "6.1.0",
|
||||
"jsonwebtoken": "9.0.0",
|
||||
"lodash": "4.17.21",
|
||||
"pg-promise": "11.4.1",
|
||||
"posthog-node": "4.11.0",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-email": "3.0.7",
|
||||
"resend": "4.1.2",
|
||||
"string-similarity": "4.0.4",
|
||||
"twitter-api-v2": "1.15.0",
|
||||
"ws": "8.17.0",
|
||||
"zod": "3.21.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/cors": "2.8.17",
|
||||
"@types/ws": "8.5.10"
|
||||
}
|
||||
}
|
||||
187
backend/api/src/app.ts
Normal file
187
backend/api/src/app.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import { API, type APIPath } from 'common/api/schema'
|
||||
import { APIError, pathWithPrefix } from 'common/api/utils'
|
||||
import cors from 'cors'
|
||||
import * as crypto from 'crypto'
|
||||
import express from 'express'
|
||||
import { type ErrorRequestHandler, type RequestHandler } from 'express'
|
||||
import { hrtime } from 'node:process'
|
||||
import { withMonitoringContext } from 'shared/monitoring/context'
|
||||
import { log } from 'shared/monitoring/log'
|
||||
import { metrics } from 'shared/monitoring/metrics'
|
||||
import { banUser } from './ban-user'
|
||||
import { blockUser, unblockUser } from './block-user'
|
||||
import { getCompatibleLoversHandler } from './compatible-lovers'
|
||||
import { createComment } from './create-comment'
|
||||
import { createCompatibilityQuestion } from './create-compatibility-question'
|
||||
import { createLover } from './create-lover'
|
||||
import { createUser } from './create-user'
|
||||
import { getCompatibilityQuestions } from './get-compatibililty-questions'
|
||||
import { getLikesAndShips } from './get-likes-and-ships'
|
||||
import { getLoverAnswers } from './get-lover-answers'
|
||||
import { getLovers } from './get-lovers'
|
||||
import { getSupabaseToken } from './get-supabase-token'
|
||||
import { getDisplayUser, getUser } from './get-user'
|
||||
import { getMe } from './get-me'
|
||||
import { hasFreeLike } from './has-free-like'
|
||||
import { health } from './health'
|
||||
import { typedEndpoint, type APIHandler } from './helpers/endpoint'
|
||||
import { hideComment } from './hide-comment'
|
||||
import { likeLover } from './like-lover'
|
||||
import { markAllNotifsRead } from './mark-all-notifications-read'
|
||||
import { removePinnedPhoto } from './remove-pinned-photo'
|
||||
import { report } from './report'
|
||||
import { searchLocation } from './search-location'
|
||||
import { searchNearCity } from './search-near-city'
|
||||
import { shipLovers } from './ship-lovers'
|
||||
import { starLover } from './star-lover'
|
||||
import { updateLover } from './update-lover'
|
||||
import { updateMe } from './update-me'
|
||||
import { deleteMe } from './delete-me'
|
||||
import { getCurrentPrivateUser } from './get-current-private-user'
|
||||
import { createPrivateUserMessage } from './create-private-user-message'
|
||||
import {
|
||||
getChannelMemberships,
|
||||
getChannelMessages,
|
||||
getLastSeenChannelTime,
|
||||
setChannelLastSeenTime,
|
||||
} from 'api/get-private-messages'
|
||||
import { searchUsers } from './search-users'
|
||||
import { createPrivateUserMessageChannel } from './create-private-user-message-channel'
|
||||
import { leavePrivateUserMessageChannel } from './leave-private-user-message-channel'
|
||||
import { updatePrivateUserMessageChannel } from './update-private-user-message-channel'
|
||||
import { getNotifications } from './get-notifications'
|
||||
import { updateNotifSettings } from './update-notif-setting'
|
||||
|
||||
const allowCorsUnrestricted: RequestHandler = cors({})
|
||||
|
||||
function cacheController(policy?: string): RequestHandler {
|
||||
return (_req, res, next) => {
|
||||
if (policy) res.appendHeader('Cache-Control', policy)
|
||||
next()
|
||||
}
|
||||
}
|
||||
|
||||
const requestMonitoring: RequestHandler = (req, _res, next) => {
|
||||
const traceContext = req.get('X-Cloud-Trace-Context')
|
||||
const traceId = traceContext
|
||||
? traceContext.split('/')[0]
|
||||
: crypto.randomUUID()
|
||||
const context = { endpoint: req.path, traceId }
|
||||
withMonitoringContext(context, () => {
|
||||
const startTs = hrtime.bigint()
|
||||
log(`${req.method} ${req.url}`)
|
||||
metrics.inc('http/request_count', { endpoint: req.path })
|
||||
next()
|
||||
const endTs = hrtime.bigint()
|
||||
const latencyMs = Number(endTs - startTs) / 1e6
|
||||
metrics.push('http/request_latency', latencyMs, { endpoint: req.path })
|
||||
})
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
} else {
|
||||
log.error(error)
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ message: error.stack, error })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const app = express()
|
||||
app.use(requestMonitoring)
|
||||
|
||||
app.options('*', allowCorsUnrestricted)
|
||||
|
||||
const handlers: { [k in APIPath]: APIHandler<k> } = {
|
||||
health: health,
|
||||
'get-supabase-token': getSupabaseToken,
|
||||
'get-notifications': getNotifications,
|
||||
'mark-all-notifs-read': markAllNotifsRead,
|
||||
'user/:username': getUser,
|
||||
'user/:username/lite': getDisplayUser,
|
||||
'user/by-id/:id': getUser,
|
||||
'user/by-id/:id/lite': getDisplayUser,
|
||||
'user/by-id/:id/block': blockUser,
|
||||
'user/by-id/:id/unblock': unblockUser,
|
||||
'search-users': searchUsers,
|
||||
'ban-user': banUser,
|
||||
report: report,
|
||||
'create-user': createUser,
|
||||
'create-lover': createLover,
|
||||
me: getMe,
|
||||
'me/private': getCurrentPrivateUser,
|
||||
'me/update': updateMe,
|
||||
'update-notif-settings': updateNotifSettings,
|
||||
'me/delete': deleteMe,
|
||||
'update-lover': updateLover,
|
||||
'like-lover': likeLover,
|
||||
'ship-lovers': shipLovers,
|
||||
'get-likes-and-ships': getLikesAndShips,
|
||||
'has-free-like': hasFreeLike,
|
||||
'star-lover': starLover,
|
||||
'get-lovers': getLovers,
|
||||
'get-lover-answers': getLoverAnswers,
|
||||
'get-compatibility-questions': getCompatibilityQuestions,
|
||||
'remove-pinned-photo': removePinnedPhoto,
|
||||
'create-comment': createComment,
|
||||
'hide-comment': hideComment,
|
||||
'create-compatibility-question': createCompatibilityQuestion,
|
||||
'compatible-lovers': getCompatibleLoversHandler,
|
||||
'search-location': searchLocation,
|
||||
'search-near-city': searchNearCity,
|
||||
'create-private-user-message': createPrivateUserMessage,
|
||||
'create-private-user-message-channel': createPrivateUserMessageChannel,
|
||||
'update-private-user-message-channel': updatePrivateUserMessageChannel,
|
||||
'leave-private-user-message-channel': leavePrivateUserMessageChannel,
|
||||
'get-channel-memberships': getChannelMemberships,
|
||||
'get-channel-messages': getChannelMessages,
|
||||
'get-channel-seen-time': getLastSeenChannelTime,
|
||||
'set-channel-seen-time': setChannelLastSeenTime,
|
||||
}
|
||||
|
||||
Object.entries(handlers).forEach(([path, handler]) => {
|
||||
const api = API[path as APIPath]
|
||||
const cache = cacheController((api as any).cache)
|
||||
const url = '/' + pathWithPrefix(path as APIPath)
|
||||
|
||||
const apiRoute = [
|
||||
url,
|
||||
express.json(),
|
||||
allowCorsUnrestricted,
|
||||
cache,
|
||||
typedEndpoint(path as any, handler as any),
|
||||
apiErrorHandler,
|
||||
] as const
|
||||
|
||||
if (api.method === 'POST') {
|
||||
app.post(...apiRoute)
|
||||
} else if (api.method === 'GET') {
|
||||
app.get(...apiRoute)
|
||||
// } else if (api.method === 'PUT') {
|
||||
// app.put(...apiRoute)
|
||||
} else {
|
||||
throw new Error('Unsupported API method')
|
||||
}
|
||||
})
|
||||
|
||||
app.use(allowCorsUnrestricted, (req, res) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.status(200).send()
|
||||
} else {
|
||||
res
|
||||
.status(404)
|
||||
.set('Content-Type', 'application/json')
|
||||
.json({
|
||||
message: `The requested route '${req.path}' does not exist. Please check your URL for any misspellings or refer to app.ts`,
|
||||
})
|
||||
}
|
||||
})
|
||||
21
backend/api/src/ban-user.ts
Normal file
21
backend/api/src/ban-user.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { APIError, APIHandler } from 'api/helpers/endpoint'
|
||||
import { trackPublicEvent } from 'shared/analytics'
|
||||
import { throwErrorIfNotMod } from 'shared/helpers/auth'
|
||||
import { isAdminId } from 'common/envs/constants'
|
||||
import { log } from 'shared/utils'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { updateUser } from 'shared/supabase/users'
|
||||
|
||||
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')
|
||||
await trackPublicEvent(auth.uid, 'ban user', {
|
||||
userId,
|
||||
})
|
||||
await updateUser(db, userId, {
|
||||
isBannedFromPosting: !unban,
|
||||
})
|
||||
log('updated user')
|
||||
}
|
||||
36
backend/api/src/block-user.ts
Normal file
36
backend/api/src/block-user.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { APIError, APIHandler } from './helpers/endpoint'
|
||||
import { FieldVal } from 'shared/supabase/utils'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { updatePrivateUser } from 'shared/supabase/users'
|
||||
|
||||
export const blockUser: APIHandler<'user/by-id/:id/block'> = async (
|
||||
{ id },
|
||||
auth
|
||||
) => {
|
||||
if (auth.uid === id) throw new APIError(400, 'You cannot block yourself')
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
await pg.tx(async (tx) => {
|
||||
await updatePrivateUser(tx, auth.uid, {
|
||||
blockedUserIds: FieldVal.arrayConcat(id),
|
||||
})
|
||||
await updatePrivateUser(tx, id, {
|
||||
blockedByUserIds: FieldVal.arrayConcat(auth.uid),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export const unblockUser: APIHandler<'user/by-id/:id/unblock'> = async (
|
||||
{ id },
|
||||
auth
|
||||
) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
await pg.tx(async (tx) => {
|
||||
await updatePrivateUser(tx, auth.uid, {
|
||||
blockedUserIds: FieldVal.arrayRemove(id),
|
||||
})
|
||||
await updatePrivateUser(tx, id, {
|
||||
blockedByUserIds: FieldVal.arrayRemove(auth.uid),
|
||||
})
|
||||
})
|
||||
}
|
||||
61
backend/api/src/compatible-lovers.ts
Normal file
61
backend/api/src/compatible-lovers.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { groupBy, sortBy } from 'lodash'
|
||||
import { APIError, type APIHandler } from 'api/helpers/endpoint'
|
||||
import { getCompatibilityScore } from 'common/love/compatibility-score'
|
||||
import {
|
||||
getLover,
|
||||
getCompatibilityAnswers,
|
||||
getGenderCompatibleLovers,
|
||||
} from 'shared/love/supabase'
|
||||
import { log } from 'shared/utils'
|
||||
|
||||
export const getCompatibleLoversHandler: APIHandler<
|
||||
'compatible-lovers'
|
||||
> = async (props) => {
|
||||
return getCompatibleLovers(props.userId)
|
||||
}
|
||||
|
||||
export const getCompatibleLovers = async (userId: string) => {
|
||||
const lover = await getLover(userId)
|
||||
|
||||
log('got lover', {
|
||||
id: lover?.id,
|
||||
userId: lover?.user_id,
|
||||
username: lover?.user?.username,
|
||||
})
|
||||
|
||||
if (!lover) throw new APIError(404, 'Lover not found')
|
||||
|
||||
const lovers = await getGenderCompatibleLovers(lover)
|
||||
|
||||
const loverAnswers = await getCompatibilityAnswers([
|
||||
userId,
|
||||
...lovers.map((l) => l.user_id),
|
||||
])
|
||||
log('got lover answers ' + loverAnswers.length)
|
||||
|
||||
const answersByUserId = groupBy(loverAnswers, 'creator_id')
|
||||
const loverCompatibilityScores = Object.fromEntries(
|
||||
lovers.map(
|
||||
(l) =>
|
||||
[
|
||||
l.user_id,
|
||||
getCompatibilityScore(
|
||||
answersByUserId[lover.user_id] ?? [],
|
||||
answersByUserId[l.user_id] ?? []
|
||||
),
|
||||
] as const
|
||||
)
|
||||
)
|
||||
|
||||
const sortedCompatibleLovers = sortBy(
|
||||
lovers,
|
||||
(l) => loverCompatibilityScores[l.user_id].score
|
||||
).reverse()
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
lover,
|
||||
compatibleLovers: sortedCompatibleLovers,
|
||||
loverCompatibilityScores,
|
||||
}
|
||||
}
|
||||
129
backend/api/src/create-comment.ts
Normal file
129
backend/api/src/create-comment.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { APIError, APIHandler } from 'api/helpers/endpoint'
|
||||
import { type JSONContent } from '@tiptap/core'
|
||||
import { getPrivateUser, getUser } from 'shared/utils'
|
||||
import {
|
||||
createSupabaseDirectClient,
|
||||
SupabaseDirectClient,
|
||||
} from 'shared/supabase/init'
|
||||
import { getNotificationDestinationsForUser } from 'common/user-notification-preferences'
|
||||
import { Notification } from 'common/notifications'
|
||||
import { insertNotificationToSupabase } from 'shared/supabase/notifications'
|
||||
import { User } from 'common/user'
|
||||
import { richTextToString } from 'common/util/parse'
|
||||
import * as crypto from 'crypto'
|
||||
import { sendNewEndorsementEmail } from 'email/functions/helpers'
|
||||
import { type Row } from 'common/supabase/utils'
|
||||
import { broadcastUpdatedComment } from 'shared/websockets/helpers'
|
||||
import { convertComment } from 'common/supabase/comment'
|
||||
|
||||
export const MAX_COMMENT_JSON_LENGTH = 20000
|
||||
|
||||
export const createComment: APIHandler<'create-comment'> = async (
|
||||
{ userId, content: submittedContent, replyToCommentId },
|
||||
auth
|
||||
) => {
|
||||
const { creator, content } = await validateComment(
|
||||
userId,
|
||||
auth.uid,
|
||||
submittedContent
|
||||
)
|
||||
|
||||
const onUser = await getUser(userId)
|
||||
if (!onUser) throw new APIError(404, 'User not found')
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
const comment = await pg.one<Row<'lover_comments'>>(
|
||||
`insert into lover_comments (user_id, user_name, user_username, user_avatar_url, on_user_id, content, reply_to_comment_id)
|
||||
values ($1, $2, $3, $4, $5, $6, $7) returning *`,
|
||||
[
|
||||
creator.id,
|
||||
creator.name,
|
||||
creator.username,
|
||||
creator.avatarUrl,
|
||||
userId,
|
||||
content,
|
||||
replyToCommentId,
|
||||
]
|
||||
)
|
||||
if (onUser.id !== creator.id)
|
||||
await createNewCommentOnLoverNotification(
|
||||
onUser,
|
||||
creator,
|
||||
richTextToString(content),
|
||||
comment.id,
|
||||
pg
|
||||
)
|
||||
|
||||
broadcastUpdatedComment(convertComment(comment))
|
||||
|
||||
return { status: 'success' }
|
||||
}
|
||||
|
||||
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')
|
||||
|
||||
const otherUser = await getPrivateUser(userId)
|
||||
if (!otherUser) throw new APIError(404, 'Other user not found')
|
||||
if (otherUser.blockedUserIds.includes(creatorId)) {
|
||||
throw new APIError(404, 'User has blocked you')
|
||||
}
|
||||
|
||||
if (JSON.stringify(content).length > MAX_COMMENT_JSON_LENGTH) {
|
||||
throw new APIError(
|
||||
400,
|
||||
`Comment is too long; should be less than ${MAX_COMMENT_JSON_LENGTH} as a JSON string.`
|
||||
)
|
||||
}
|
||||
return { content, creator }
|
||||
}
|
||||
|
||||
const createNewCommentOnLoverNotification = async (
|
||||
onUser: User,
|
||||
creator: User,
|
||||
sourceText: string,
|
||||
commentId: number,
|
||||
pg: SupabaseDirectClient
|
||||
) => {
|
||||
const privateUser = await getPrivateUser(onUser.id)
|
||||
if (!privateUser) return
|
||||
const id = crypto.randomUUID()
|
||||
const reason = 'new_endorsement'
|
||||
const { sendToBrowser, sendToMobile, sendToEmail } =
|
||||
getNotificationDestinationsForUser(privateUser, reason)
|
||||
const notification: Notification = {
|
||||
id,
|
||||
userId: privateUser.id,
|
||||
reason,
|
||||
createdTime: Date.now(),
|
||||
isSeen: false,
|
||||
sourceId: commentId.toString(),
|
||||
sourceType: 'comment_on_lover',
|
||||
sourceUpdateType: 'created',
|
||||
sourceUserName: creator.name,
|
||||
sourceUserUsername: creator.username,
|
||||
sourceUserAvatarUrl: creator.avatarUrl,
|
||||
sourceText: sourceText,
|
||||
sourceSlug: onUser.username,
|
||||
}
|
||||
if (sendToBrowser) {
|
||||
await insertNotificationToSupabase(notification, pg)
|
||||
}
|
||||
if (sendToMobile) {
|
||||
// await createPushNotification(
|
||||
// notification,
|
||||
// privateUser,
|
||||
// `${creator.name} commented on your profile`,
|
||||
// sourceText
|
||||
// )
|
||||
}
|
||||
if (sendToEmail) {
|
||||
await sendNewEndorsementEmail(privateUser, creator, onUser, sourceText)
|
||||
}
|
||||
}
|
||||
27
backend/api/src/create-compatibility-question.ts
Normal file
27
backend/api/src/create-compatibility-question.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 createCompatibilityQuestion: APIHandler<
|
||||
'create-compatibility-question'
|
||||
> = async ({ question, options }, auth) => {
|
||||
const creator = await getUser(auth.uid)
|
||||
if (!creator) throw new APIError(401, 'Your account was not found')
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const { data, error } = await tryCatch(
|
||||
insert(pg, 'love_questions', {
|
||||
creator_id: creator.id,
|
||||
question,
|
||||
answer_type: 'compatibility_multiple_choice',
|
||||
multiple_choice_options: options,
|
||||
})
|
||||
)
|
||||
|
||||
if (error) throw new APIError(401, 'Error creating question')
|
||||
|
||||
return { question: data }
|
||||
}
|
||||
44
backend/api/src/create-lover.ts
Normal file
44
backend/api/src/create-lover.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { APIError, APIHandler } from 'api/helpers/endpoint'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { log, getUser } from 'shared/utils'
|
||||
import { HOUR_MS } from 'common/util/time'
|
||||
import { removePinnedUrlFromPhotoUrls } from 'shared/love/parse-photos'
|
||||
import { track } from 'shared/analytics'
|
||||
import { updateUser } from 'shared/supabase/users'
|
||||
import { tryCatch } from 'common/util/try-catch'
|
||||
import { insert } from 'shared/supabase/utils'
|
||||
|
||||
export const createLover: APIHandler<'create-lover'> = async (body, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const { data: existingUser } = await tryCatch(
|
||||
pg.oneOrNone<{ id: string }>('select id from lovers where user_id = $1', [
|
||||
auth.uid,
|
||||
])
|
||||
)
|
||||
if (existingUser) {
|
||||
throw new APIError(400, 'User already exists')
|
||||
}
|
||||
|
||||
await removePinnedUrlFromPhotoUrls(body)
|
||||
const user = await getUser(auth.uid)
|
||||
if (!user) throw new APIError(401, 'Your account was not found')
|
||||
if (user.createdTime > Date.now() - HOUR_MS) {
|
||||
// If they just signed up for manifold via manifold.love, set their avatar to be their pinned photo
|
||||
updateUser(pg, auth.uid, { avatarUrl: body.pinned_url })
|
||||
}
|
||||
|
||||
const { data, error } = await tryCatch(
|
||||
insert(pg, 'lovers', { user_id: auth.uid, ...body })
|
||||
)
|
||||
|
||||
if (error) {
|
||||
log.error('Error creating user: ' + error.message)
|
||||
throw new APIError(500, 'Error creating user')
|
||||
}
|
||||
|
||||
log('Created user', data)
|
||||
await track(user.id, 'create lover', { username: user.username })
|
||||
|
||||
return data
|
||||
}
|
||||
71
backend/api/src/create-private-user-message-channel.ts
Normal file
71
backend/api/src/create-private-user-message-channel.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { APIError, APIHandler } from 'api/helpers/endpoint'
|
||||
import { filterDefined } from 'common/util/array'
|
||||
import { uniq } from 'lodash'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { addUsersToPrivateMessageChannel } from 'api/junk-drawer/private-messages'
|
||||
import { getPrivateUser, getUser } from 'shared/utils'
|
||||
|
||||
export const createPrivateUserMessageChannel: APIHandler<
|
||||
'create-private-user-message-channel'
|
||||
> = async (body, auth) => {
|
||||
const userIds = uniq(body.userIds.concat(auth.uid))
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
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')
|
||||
const toPrivateUsers = filterDefined(
|
||||
await Promise.all(userIds.map((id) => getPrivateUser(id)))
|
||||
)
|
||||
|
||||
if (toPrivateUsers.length !== userIds.length)
|
||||
throw new APIError(
|
||||
404,
|
||||
`Private user ${userIds.find(
|
||||
(uid) => !toPrivateUsers.map((p) => p.id).includes(uid)
|
||||
)} not found`
|
||||
)
|
||||
|
||||
if (
|
||||
toPrivateUsers.some((user) =>
|
||||
user.blockedUserIds.some((blockedId) => userIds.includes(blockedId))
|
||||
)
|
||||
) {
|
||||
throw new APIError(
|
||||
403,
|
||||
'One of the users has blocked another user in the list'
|
||||
)
|
||||
}
|
||||
|
||||
const currentChannel = await pg.oneOrNone(
|
||||
`
|
||||
select channel_id from private_user_message_channel_members
|
||||
group by channel_id
|
||||
having array_agg(user_id::text) @> array[$1]::text[]
|
||||
and array_agg(user_id::text) <@ array[$1]::text[]
|
||||
`,
|
||||
[userIds]
|
||||
)
|
||||
if (currentChannel)
|
||||
return {
|
||||
status: 'success',
|
||||
channelId: Number(currentChannel.channel_id),
|
||||
}
|
||||
|
||||
const channel = await pg.one(
|
||||
`insert into private_user_message_channels default values returning id`
|
||||
)
|
||||
|
||||
await pg.none(
|
||||
`insert into private_user_message_channel_members (channel_id, user_id, role, status)
|
||||
values ($1, $2, 'creator', 'joined')
|
||||
`,
|
||||
[channel.id, creatorId]
|
||||
)
|
||||
|
||||
const memberIds = userIds.filter((id) => id !== creatorId)
|
||||
await addUsersToPrivateMessageChannel(memberIds, channel.id, pg)
|
||||
return { status: 'success', channelId: Number(channel.id) }
|
||||
}
|
||||
28
backend/api/src/create-private-user-message.ts
Normal file
28
backend/api/src/create-private-user-message.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { APIError, APIHandler } from 'api/helpers/endpoint'
|
||||
import { getUser } from 'shared/utils'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { MAX_COMMENT_JSON_LENGTH } from 'api/create-comment'
|
||||
import { createPrivateUserMessageMain } from 'api/junk-drawer/private-messages'
|
||||
|
||||
export const createPrivateUserMessage: APIHandler<
|
||||
'create-private-user-message'
|
||||
> = async (body, auth) => {
|
||||
const { content, channelId } = body
|
||||
if (JSON.stringify(content).length > MAX_COMMENT_JSON_LENGTH) {
|
||||
throw new APIError(
|
||||
400,
|
||||
`Message JSON should be less than ${MAX_COMMENT_JSON_LENGTH}`
|
||||
)
|
||||
}
|
||||
const pg = createSupabaseDirectClient()
|
||||
const creator = await getUser(auth.uid)
|
||||
if (!creator) throw new APIError(401, 'Your account was not found')
|
||||
if (creator.isBannedFromPosting) throw new APIError(403, 'You are banned')
|
||||
return await createPrivateUserMessageMain(
|
||||
creator,
|
||||
channelId,
|
||||
content,
|
||||
pg,
|
||||
'private'
|
||||
)
|
||||
}
|
||||
157
backend/api/src/create-user.ts
Normal file
157
backend/api/src/create-user.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
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'
|
||||
|
||||
export const createUser: APIHandler<'create-user'> = async (
|
||||
props,
|
||||
auth,
|
||||
req
|
||||
) => {
|
||||
const { deviceToken: preDeviceToken, adminToken } = 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 host = req.get('referer')
|
||||
log(`Create user from: ${host}`)
|
||||
|
||||
const ip = getIp(req)
|
||||
const deviceToken = testUserAKAEmailPasswordUser
|
||||
? randomString() + randomString()
|
||||
: preDeviceToken
|
||||
|
||||
const fbUser = await admin.auth().getUser(auth.uid)
|
||||
const email = fbUser.email
|
||||
const emailName = email?.replace(/@.*$/, '')
|
||||
|
||||
const rawName = fbUser.displayName || emailName || 'User' + randomString(4)
|
||||
const name = cleanDisplayName(rawName)
|
||||
|
||||
const bucket = getStorage().bucket(getStorageBucketId())
|
||||
const avatarUrl = fbUser.photoURL
|
||||
? fbUser.photoURL
|
||||
: await generateAvatarUrl(auth.uid, name, bucket)
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
let username = cleanUsername(name)
|
||||
|
||||
// Check username case-insensitive
|
||||
const dupes = await pg.one<number>(
|
||||
`select count(*) from users where username ilike $1`,
|
||||
[username],
|
||||
(r) => r.count
|
||||
)
|
||||
const usernameExists = dupes > 0
|
||||
const isReservedName = RESERVED_PATHS.includes(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,
|
||||
})
|
||||
|
||||
// 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 })
|
||||
|
||||
const user = removeUndefinedProps({
|
||||
avatarUrl,
|
||||
isBannedFromPosting: Boolean(
|
||||
(deviceToken && bannedDeviceTokens.includes(deviceToken)) ||
|
||||
(ip && bannedIpAddresses.includes(ip))
|
||||
),
|
||||
link: {},
|
||||
})
|
||||
|
||||
const privateUser: PrivateUser = {
|
||||
id: auth.uid,
|
||||
email,
|
||||
initialIpAddress: ip,
|
||||
initialDeviceToken: deviceToken,
|
||||
notificationPreferences: getDefaultNotificationPreferences(),
|
||||
blockedUserIds: [],
|
||||
blockedByUserIds: [],
|
||||
}
|
||||
|
||||
const newUserRow = await insert(tx, 'users', {
|
||||
id: auth.uid,
|
||||
name,
|
||||
username,
|
||||
data: user,
|
||||
})
|
||||
|
||||
const newPrivateUserRow = await insert(tx, 'private_users', {
|
||||
id: privateUser.id,
|
||||
data: privateUser,
|
||||
})
|
||||
|
||||
return {
|
||||
user: convertUser(newUserRow),
|
||||
privateUser: convertPrivateUser(newPrivateUserRow),
|
||||
}
|
||||
})
|
||||
|
||||
log('created user ', { username: user.username, firebaseId: auth.uid })
|
||||
|
||||
const continuation = async () => {
|
||||
await track(auth.uid, 'create lover', { username: user.username })
|
||||
}
|
||||
|
||||
return {
|
||||
result: {
|
||||
user,
|
||||
privateUser,
|
||||
},
|
||||
continue: continuation,
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
'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',
|
||||
]
|
||||
28
backend/api/src/delete-me.ts
Normal file
28
backend/api/src/delete-me.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
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'
|
||||
|
||||
export const deleteMe: APIHandler<'me/delete'> = async (body, auth) => {
|
||||
const { username } = body
|
||||
const user = await getUser(auth.uid)
|
||||
if (!user) {
|
||||
throw new APIError(401, 'Your account was not found')
|
||||
}
|
||||
if (user.username != username) {
|
||||
throw new APIError(
|
||||
400,
|
||||
`Incorrect username. You are logged in as ${user.username}. Are you sure you want to delete this account?`
|
||||
)
|
||||
}
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
await updateUser(pg, auth.uid, {
|
||||
userDeleted: true,
|
||||
isBannedFromPosting: true,
|
||||
})
|
||||
await updatePrivateUser(pg, auth.uid, {
|
||||
email: FieldVal.delete(),
|
||||
})
|
||||
}
|
||||
41
backend/api/src/get-compatibililty-questions.ts
Normal file
41
backend/api/src/get-compatibililty-questions.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { type APIHandler } from 'api/helpers/endpoint'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { Row } from 'common/supabase/utils'
|
||||
|
||||
export const getCompatibilityQuestions: APIHandler<
|
||||
'get-compatibility-questions'
|
||||
> = async (_props, _auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const questions = await pg.manyOrNone<
|
||||
Row<'love_questions'> & { answer_count: number; score: number }
|
||||
>(
|
||||
`SELECT
|
||||
love_questions.*,
|
||||
COUNT(love_compatibility_answers.question_id) as answer_count,
|
||||
AVG(POWER(love_compatibility_answers.importance + 1 + CASE WHEN love_compatibility_answers.explanation IS NULL THEN 1 ELSE 0 END, 2)) as score
|
||||
FROM
|
||||
love_questions
|
||||
LEFT JOIN
|
||||
love_compatibility_answers ON love_questions.id = love_compatibility_answers.question_id
|
||||
WHERE
|
||||
love_questions.answer_type = 'compatibility_multiple_choice'
|
||||
GROUP BY
|
||||
love_questions.id
|
||||
ORDER BY
|
||||
score DESC
|
||||
`,
|
||||
[]
|
||||
)
|
||||
|
||||
if (false)
|
||||
console.log(
|
||||
'got questions',
|
||||
questions.map((q) => q.question + ' ' + q.score)
|
||||
)
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
questions,
|
||||
}
|
||||
}
|
||||
32
backend/api/src/get-current-private-user.ts
Normal file
32
backend/api/src/get-current-private-user.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { APIError, APIHandler } from './helpers/endpoint'
|
||||
import { PrivateUser } from 'common/user'
|
||||
import { Row } from 'common/supabase/utils'
|
||||
import { tryCatch } from 'common/util/try-catch'
|
||||
|
||||
export const getCurrentPrivateUser: APIHandler<'me/private'> = async (
|
||||
_,
|
||||
auth
|
||||
) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const { data, error } = await tryCatch(
|
||||
pg.oneOrNone<Row<'private_users'>>(
|
||||
'select * from private_users where id = $1',
|
||||
[auth.uid]
|
||||
)
|
||||
)
|
||||
|
||||
if (error) {
|
||||
throw new APIError(
|
||||
500,
|
||||
'Error fetching private user data: ' + error.message
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
throw new APIError(401, 'Your account was not found')
|
||||
}
|
||||
|
||||
return data.data as PrivateUser
|
||||
}
|
||||
106
backend/api/src/get-likes-and-ships.ts
Normal file
106
backend/api/src/get-likes-and-ships.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { type APIHandler } from 'api/helpers/endpoint'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
|
||||
export const getLikesAndShips: APIHandler<'get-likes-and-ships'> = async (
|
||||
props
|
||||
) => {
|
||||
const { userId } = props
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
...(await getLikesAndShipsMain(userId)),
|
||||
}
|
||||
}
|
||||
|
||||
export const getLikesAndShipsMain = async (userId: string) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const likesGiven = await pg.map<{
|
||||
user_id: string
|
||||
created_time: number
|
||||
}>(
|
||||
`
|
||||
select target_id, love_likes.created_time
|
||||
from love_likes
|
||||
join lovers on lovers.user_id = love_likes.target_id
|
||||
join users on users.id = love_likes.target_id
|
||||
where creator_id = $1
|
||||
and looking_for_matches
|
||||
and lovers.pinned_url is not null
|
||||
and (data->>'isBannedFromPosting' != 'true' or data->>'isBannedFromPosting' is null)
|
||||
order by created_time desc
|
||||
`,
|
||||
[userId],
|
||||
(r) => ({
|
||||
user_id: r.target_id,
|
||||
created_time: new Date(r.created_time).getTime(),
|
||||
})
|
||||
)
|
||||
|
||||
const likesReceived = await pg.map<{
|
||||
user_id: string
|
||||
created_time: number
|
||||
}>(
|
||||
`
|
||||
select creator_id, love_likes.created_time
|
||||
from love_likes
|
||||
join lovers on lovers.user_id = love_likes.creator_id
|
||||
join users on users.id = love_likes.creator_id
|
||||
where target_id = $1
|
||||
and looking_for_matches
|
||||
and lovers.pinned_url is not null
|
||||
and (data->>'isBannedFromPosting' != 'true' or data->>'isBannedFromPosting' is null)
|
||||
order by created_time desc
|
||||
`,
|
||||
[userId],
|
||||
(r) => ({
|
||||
user_id: r.creator_id,
|
||||
created_time: new Date(r.created_time).getTime(),
|
||||
})
|
||||
)
|
||||
|
||||
const ships = await pg.map<{
|
||||
creator_id: string
|
||||
target_id: string
|
||||
target1_id: string
|
||||
target2_id: string
|
||||
created_time: number
|
||||
}>(
|
||||
`
|
||||
select
|
||||
target1_id, target2_id, creator_id, love_ships.created_time,
|
||||
target1_id as target_id
|
||||
from love_ships
|
||||
join lovers on lovers.user_id = love_ships.target1_id
|
||||
join users on users.id = love_ships.target1_id
|
||||
where target2_id = $1
|
||||
and lovers.looking_for_matches
|
||||
and lovers.pinned_url is not null
|
||||
and (users.data->>'isBannedFromPosting' != 'true' or users.data->>'isBannedFromPosting' is null)
|
||||
|
||||
union all
|
||||
|
||||
select
|
||||
target1_id, target2_id, creator_id, love_ships.created_time,
|
||||
target2_id as target_id
|
||||
from love_ships
|
||||
join lovers on lovers.user_id = love_ships.target2_id
|
||||
join users on users.id = love_ships.target2_id
|
||||
where target1_id = $1
|
||||
and lovers.looking_for_matches
|
||||
and lovers.pinned_url is not null
|
||||
and (users.data->>'isBannedFromPosting' != 'true' or users.data->>'isBannedFromPosting' is null)
|
||||
`,
|
||||
[userId],
|
||||
(r) => ({
|
||||
...r,
|
||||
created_time: new Date(r.created_time).getTime(),
|
||||
})
|
||||
)
|
||||
|
||||
return {
|
||||
likesGiven,
|
||||
likesReceived,
|
||||
ships,
|
||||
}
|
||||
}
|
||||
25
backend/api/src/get-lover-answers.ts
Normal file
25
backend/api/src/get-lover-answers.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { type APIHandler } from 'api/helpers/endpoint'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { Row } from 'common/supabase/utils'
|
||||
|
||||
export const getLoverAnswers: APIHandler<'get-lover-answers'> = async (
|
||||
props,
|
||||
_auth
|
||||
) => {
|
||||
const { userId } = props
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const answers = await pg.manyOrNone<Row<'love_compatibility_answers'>>(
|
||||
`select * from love_compatibility_answers
|
||||
where
|
||||
creator_id = $1
|
||||
order by created_time desc
|
||||
`,
|
||||
[userId]
|
||||
)
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
answers,
|
||||
}
|
||||
}
|
||||
134
backend/api/src/get-lovers.ts
Normal file
134
backend/api/src/get-lovers.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
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 { getCompatibleLovers } from 'api/compatible-lovers'
|
||||
import { intersection } from 'lodash'
|
||||
|
||||
export const getLovers: APIHandler<'get-lovers'> = async (props, _auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
const {
|
||||
limit: limitParam,
|
||||
after,
|
||||
name,
|
||||
genders,
|
||||
pref_gender,
|
||||
pref_age_min,
|
||||
pref_age_max,
|
||||
pref_relation_styles,
|
||||
wants_kids_strength,
|
||||
has_kids,
|
||||
is_smoker,
|
||||
geodbCityIds,
|
||||
compatibleWithUserId,
|
||||
orderBy: orderByParam,
|
||||
} = props
|
||||
|
||||
// compatibility. TODO: do this in sql
|
||||
if (orderByParam === 'compatibility_score') {
|
||||
if (!compatibleWithUserId) return { status: 'fail', lovers: [] }
|
||||
|
||||
const { compatibleLovers } = await getCompatibleLovers(compatibleWithUserId)
|
||||
const lovers = compatibleLovers.filter(
|
||||
(l) =>
|
||||
(!name || l.user.name.toLowerCase().includes(name.toLowerCase())) &&
|
||||
(!genders || genders.includes(l.gender)) &&
|
||||
(!pref_gender || intersection(pref_gender, l.pref_gender).length) &&
|
||||
(!pref_age_min || l.age >= pref_age_min) &&
|
||||
(!pref_age_max || l.age <= pref_age_max) &&
|
||||
(!pref_relation_styles ||
|
||||
intersection(pref_relation_styles, l.pref_relation_styles).length) &&
|
||||
(!wants_kids_strength ||
|
||||
wants_kids_strength == -1 ||
|
||||
(wants_kids_strength >= 2
|
||||
? l.wants_kids_strength >= wants_kids_strength
|
||||
: l.wants_kids_strength <= wants_kids_strength)) &&
|
||||
(has_kids == undefined ||
|
||||
has_kids == -1 ||
|
||||
(has_kids == 0 && !l.has_kids) ||
|
||||
(l.has_kids && l.has_kids > 0)) &&
|
||||
(!is_smoker || l.is_smoker === is_smoker) &&
|
||||
(!geodbCityIds ||
|
||||
(l.geodb_city_id && geodbCityIds.includes(l.geodb_city_id)))
|
||||
)
|
||||
|
||||
const cursor = after
|
||||
? lovers.findIndex((l) => l.id.toString() === after) + 1
|
||||
: 0
|
||||
console.log(cursor)
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
lovers: lovers.slice(cursor, cursor + limitParam),
|
||||
}
|
||||
}
|
||||
|
||||
const query = renderSql(
|
||||
select('lovers.*, name, username, users.data as user'),
|
||||
from('lovers'),
|
||||
join('users on users.id = lovers.user_id'),
|
||||
where('looking_for_matches = 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`),
|
||||
|
||||
name &&
|
||||
where(`lower(users.name) ilike '%' || lower($(name)) || '%'`, { name }),
|
||||
|
||||
genders?.length && where(`gender = ANY($(gender))`, { gender: genders }),
|
||||
|
||||
pref_gender?.length &&
|
||||
where(`pref_gender && $(pref_gender)`, { pref_gender }),
|
||||
|
||||
pref_age_min !== undefined &&
|
||||
where(`age >= $(pref_age_min)`, { pref_age_min }),
|
||||
|
||||
pref_age_max !== undefined &&
|
||||
where(`age <= $(pref_age_max)`, { pref_age_max }),
|
||||
|
||||
pref_relation_styles?.length &&
|
||||
where(`pref_relation_styles && $(pref_relation_styles)`, {
|
||||
pref_relation_styles,
|
||||
}),
|
||||
|
||||
wants_kids_strength !== undefined &&
|
||||
wants_kids_strength !== -1 &&
|
||||
where(
|
||||
wants_kids_strength >= 2
|
||||
? `wants_kids_strength >= $(wants_kids_strength)`
|
||||
: `wants_kids_strength <= $(wants_kids_strength)`,
|
||||
{ wants_kids_strength }
|
||||
),
|
||||
|
||||
has_kids === 0 && where(`has_kids IS NULL OR has_kids = 0`),
|
||||
has_kids && has_kids > 0 && where(`has_kids > 0`),
|
||||
|
||||
is_smoker !== undefined && where(`is_smoker = $(is_smoker)`, { is_smoker }),
|
||||
|
||||
geodbCityIds?.length &&
|
||||
where(`geodb_city_id = ANY($(geodbCityIds))`, { geodbCityIds }),
|
||||
|
||||
orderBy(`${orderByParam} desc`),
|
||||
after &&
|
||||
where(
|
||||
`lovers.${orderByParam} < (select lovers.${orderByParam} from lovers where id = $(after))`,
|
||||
{ after }
|
||||
),
|
||||
|
||||
limit(limitParam)
|
||||
)
|
||||
|
||||
const lovers = await pg.map(query, [], convertRow)
|
||||
|
||||
return { status: 'success', lovers: lovers }
|
||||
}
|
||||
6
backend/api/src/get-me.ts
Normal file
6
backend/api/src/get-me.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type APIHandler } from './helpers/endpoint'
|
||||
import { getUser } from 'api/get-user'
|
||||
|
||||
export const getMe: APIHandler<'me'> = async (_, auth) => {
|
||||
return getUser({ id: auth.uid })
|
||||
}
|
||||
23
backend/api/src/get-notifications.ts
Normal file
23
backend/api/src/get-notifications.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { APIHandler } from 'api/helpers/endpoint'
|
||||
import { Notification } from 'common/notifications'
|
||||
|
||||
export const getNotifications: APIHandler<'get-notifications'> = async (
|
||||
props,
|
||||
auth
|
||||
) => {
|
||||
const { limit, after } = props
|
||||
const pg = createSupabaseDirectClient()
|
||||
const query = `
|
||||
select data from user_notifications
|
||||
where user_id = $1
|
||||
and ($3 is null or (data->'createdTime')::bigint > $3)
|
||||
order by (data->'createdTime')::bigint desc
|
||||
limit $2
|
||||
`
|
||||
return await pg.map(
|
||||
query,
|
||||
[auth.uid, limit, after],
|
||||
(row) => row.data as Notification
|
||||
)
|
||||
}
|
||||
147
backend/api/src/get-private-messages.ts
Normal file
147
backend/api/src/get-private-messages.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { APIHandler } from './helpers/endpoint'
|
||||
import {
|
||||
convertPrivateChatMessage,
|
||||
PrivateMessageChannel,
|
||||
} from 'common/supabase/private-messages'
|
||||
import { groupBy, mapValues } from 'lodash'
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
export const getChannelMessages: APIHandler<'get-channel-messages'> = async (
|
||||
props,
|
||||
auth
|
||||
) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
const { channelId, limit, id } = props
|
||||
return await pg.map(
|
||||
`select *, created_time as created_time_ts
|
||||
from private_user_messages
|
||||
where channel_id = $1
|
||||
and exists (select 1 from private_user_message_channel_members pumcm
|
||||
where pumcm.user_id = $2
|
||||
and pumcm.channel_id = $1
|
||||
)
|
||||
and ($4 is null or id > $4)
|
||||
and not visibility = 'system_status'
|
||||
order by created_time desc
|
||||
limit $3
|
||||
`,
|
||||
[channelId, auth.uid, limit, id],
|
||||
convertPrivateChatMessage
|
||||
)
|
||||
}
|
||||
|
||||
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]
|
||||
)
|
||||
}
|
||||
33
backend/api/src/get-supabase-token.ts
Normal file
33
backend/api/src/get-supabase-token.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
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'
|
||||
|
||||
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.")
|
||||
}
|
||||
const instanceId = isProd()
|
||||
? PROD_CONFIG.supabaseInstanceId
|
||||
: DEV_CONFIG.supabaseInstanceId
|
||||
if (!instanceId) {
|
||||
throw new APIError(500, 'No Supabase instance ID in config.')
|
||||
}
|
||||
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,
|
||||
subject: auth.uid,
|
||||
}),
|
||||
}
|
||||
}
|
||||
33
backend/api/src/get-user.ts
Normal file
33
backend/api/src/get-user.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { toUserAPIResponse } from 'common/api/user-types'
|
||||
import { convertUser, displayUserColumns } from 'common/supabase/users'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { APIError } from 'common/api/utils'
|
||||
import { removeNullOrUndefinedProps } from 'common/util/object'
|
||||
|
||||
export const getUser = async (props: { id: string } | { username: string }) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
const user = await pg.oneOrNone(
|
||||
`select * from users
|
||||
where ${'id' in props ? 'id' : 'username'} = $1`,
|
||||
['id' in props ? props.id : props.username],
|
||||
(r) => (r ? convertUser(r) : null)
|
||||
)
|
||||
if (!user) throw new APIError(404, 'User not found')
|
||||
|
||||
return toUserAPIResponse(user)
|
||||
}
|
||||
|
||||
export const getDisplayUser = async (
|
||||
props: { id: string } | { username: string }
|
||||
) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
const liteUser = await pg.oneOrNone(
|
||||
`select ${displayUserColumns}
|
||||
from users
|
||||
where ${'id' in props ? 'id' : 'username'} = $1`,
|
||||
['id' in props ? props.id : props.username]
|
||||
)
|
||||
if (!liteUser) throw new APIError(404, 'User not found')
|
||||
|
||||
return removeNullOrUndefinedProps(liteUser)
|
||||
}
|
||||
29
backend/api/src/has-free-like.ts
Normal file
29
backend/api/src/has-free-like.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { type APIHandler } from 'api/helpers/endpoint'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
|
||||
export const hasFreeLike: APIHandler<'has-free-like'> = async (
|
||||
_props,
|
||||
auth
|
||||
) => {
|
||||
return {
|
||||
status: 'success',
|
||||
hasFreeLike: await getHasFreeLike(auth.uid),
|
||||
}
|
||||
}
|
||||
|
||||
export const getHasFreeLike = async (userId: string) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const likeGivenToday = await pg.oneOrNone<object>(
|
||||
`
|
||||
select 1
|
||||
from love_likes
|
||||
where creator_id = $1
|
||||
and created_time at time zone 'UTC' at time zone 'America/Los_Angeles' >= (now() at time zone 'UTC' at time zone 'America/Los_Angeles')::date
|
||||
and created_time at time zone 'UTC' at time zone 'America/Los_Angeles' < ((now() at time zone 'UTC' at time zone 'America/Los_Angeles')::date + interval '1 day')
|
||||
limit 1
|
||||
`,
|
||||
[userId]
|
||||
)
|
||||
return !likeGivenToday
|
||||
}
|
||||
8
backend/api/src/health.ts
Normal file
8
backend/api/src/health.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { APIHandler } from './helpers/endpoint'
|
||||
|
||||
export const health: APIHandler<'health'> = async (_, auth) => {
|
||||
return {
|
||||
message: 'Server is working.',
|
||||
uid: auth?.uid,
|
||||
}
|
||||
}
|
||||
219
backend/api/src/helpers/endpoint.ts
Normal file
219
backend/api/src/helpers/endpoint.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
import * as admin from 'firebase-admin'
|
||||
import { z } from 'zod'
|
||||
import { Request, Response, NextFunction } 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'
|
||||
|
||||
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
|
||||
creds: JwtCredentials | (KeyCredentials & { privateUser: PrivateUser })
|
||||
}
|
||||
type JwtCredentials = { kind: 'jwt'; data: admin.auth.DecodedIdToken }
|
||||
type KeyCredentials = { kind: 'key'; data: string }
|
||||
type Credentials = JwtCredentials | KeyCredentials
|
||||
|
||||
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.')
|
||||
}
|
||||
const authParts = authHeader.split(' ')
|
||||
if (authParts.length !== 2) {
|
||||
throw new APIError(401, 'Invalid Authorization header.')
|
||||
}
|
||||
|
||||
const [scheme, payload] = authParts
|
||||
switch (scheme) {
|
||||
case 'Bearer':
|
||||
if (payload === 'undefined') {
|
||||
throw new APIError(401, 'Firebase JWT payload undefined.')
|
||||
}
|
||||
try {
|
||||
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 }
|
||||
default:
|
||||
throw new APIError(401, 'Invalid auth scheme; must be "Key" or "Bearer".')
|
||||
}
|
||||
}
|
||||
|
||||
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 Manifold user ID.')
|
||||
}
|
||||
return { uid: creds.data.user_id, creds }
|
||||
}
|
||||
case 'key': {
|
||||
const key = creds.data
|
||||
const privateUser = await getPrivateUserByKey(key)
|
||||
if (!privateUser) {
|
||||
throw new APIError(401, `No private user exists with API key ${key}.`)
|
||||
}
|
||||
return { uid: privateUser.id, creds: { privateUser, ...creds } }
|
||||
}
|
||||
default:
|
||||
throw new APIError(401, 'Invalid credential type.')
|
||||
}
|
||||
}
|
||||
|
||||
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) => {
|
||||
return {
|
||||
field: i.path.join('.') || null,
|
||||
error: i.message,
|
||||
}
|
||||
})
|
||||
if (issues.length > 0) {
|
||||
log.error(issues.map((i) => `${i.field}: ${i.error}`).join('\n'))
|
||||
}
|
||||
throw new APIError(400, 'Error validating request.', issues)
|
||||
} else {
|
||||
return result.data as z.infer<T>
|
||||
}
|
||||
}
|
||||
|
||||
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>,
|
||||
auth: APISchema<N> extends { authed: true }
|
||||
? AuthedUser
|
||||
: AuthedUser | undefined,
|
||||
req: Request
|
||||
) => Promise<APIResponseOptionalContinue<N>>
|
||||
|
||||
export const typedEndpoint = <N extends APIPath>(
|
||||
name: N,
|
||||
handler: APIHandler<N>
|
||||
) => {
|
||||
const { props: propSchema, authed: authRequired, method } = API[name]
|
||||
|
||||
return async (req: Request, res: Response, next: NextFunction) => {
|
||||
let authUser: AuthedUser | undefined = undefined
|
||||
try {
|
||||
authUser = await lookupUser(await parseCredentials(req))
|
||||
} catch (e) {
|
||||
if (authRequired) return next(e)
|
||||
}
|
||||
|
||||
const props = {
|
||||
...(method === 'GET' ? req.query : req.body),
|
||||
...req.params,
|
||||
}
|
||||
|
||||
try {
|
||||
const resultOptionalContinue = await handler(
|
||||
validate(propSchema, props),
|
||||
authUser as AuthedUser,
|
||||
req
|
||||
)
|
||||
|
||||
const hasContinue =
|
||||
resultOptionalContinue &&
|
||||
'continue' in resultOptionalContinue &&
|
||||
'result' in resultOptionalContinue
|
||||
const result = hasContinue
|
||||
? resultOptionalContinue.result
|
||||
: resultOptionalContinue
|
||||
|
||||
if (!res.headersSent) {
|
||||
// Convert bigint to number, b/c JSON doesn't support bigint.
|
||||
const convertedResult = deepConvertBigIntToNumber(result)
|
||||
|
||||
res.status(200).json(convertedResult ?? { success: true })
|
||||
}
|
||||
|
||||
if (hasContinue) {
|
||||
await resultOptionalContinue.continue()
|
||||
}
|
||||
} catch (error) {
|
||||
next(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const deepConvertBigIntToNumber = (obj: any): any => {
|
||||
if (typeof obj === 'bigint') {
|
||||
return Number(obj)
|
||||
} else if (obj && typeof obj === 'object') {
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
obj[key] = deepConvertBigIntToNumber(value)
|
||||
}
|
||||
}
|
||||
return obj
|
||||
}
|
||||
35
backend/api/src/hide-comment.ts
Normal file
35
backend/api/src/hide-comment.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { APIError, APIHandler } from 'api/helpers/endpoint'
|
||||
import { isAdminId } from 'common/envs/constants'
|
||||
import { convertComment } from 'common/supabase/comment'
|
||||
import { Row } from 'common/supabase/utils'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { broadcastUpdatedComment } from 'shared/websockets/helpers'
|
||||
|
||||
export const hideComment: APIHandler<'hide-comment'> = async (
|
||||
{ commentId, hide },
|
||||
auth
|
||||
) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
const comment = await pg.oneOrNone<Row<'lover_comments'>>(
|
||||
`select * from lover_comments where id = $1`,
|
||||
[commentId]
|
||||
)
|
||||
if (!comment) {
|
||||
throw new APIError(404, 'Comment not found')
|
||||
}
|
||||
|
||||
if (
|
||||
!isAdminId(auth.uid) &&
|
||||
comment.user_id !== auth.uid &&
|
||||
comment.on_user_id !== auth.uid
|
||||
) {
|
||||
throw new APIError(403, 'You are not allowed to hide this comment')
|
||||
}
|
||||
|
||||
await pg.none(`update lover_comments set hidden = $2 where id = $1`, [
|
||||
commentId,
|
||||
hide,
|
||||
])
|
||||
|
||||
broadcastUpdatedComment(convertComment(comment))
|
||||
}
|
||||
178
backend/api/src/junk-drawer/private-messages.ts
Normal file
178
backend/api/src/junk-drawer/private-messages.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { Json } from 'common/supabase/schema'
|
||||
import { SupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { ChatVisibility } from 'common/chat-message'
|
||||
import { User } from 'common/user'
|
||||
import { first } from 'lodash'
|
||||
import { log } from 'shared/monitoring/log'
|
||||
import { getPrivateUser, getUser } from 'shared/utils'
|
||||
import { type JSONContent } from '@tiptap/core'
|
||||
import { APIError } from 'common/api/utils'
|
||||
import { broadcast } from 'shared/websockets/server'
|
||||
import { track } from 'shared/analytics'
|
||||
import { sendNewMessageEmail } from 'email/functions/helpers'
|
||||
import dayjs from 'dayjs'
|
||||
import utc from 'dayjs/plugin/utc'
|
||||
import timezone from 'dayjs/plugin/timezone'
|
||||
|
||||
dayjs.extend(utc)
|
||||
dayjs.extend(timezone)
|
||||
|
||||
export const leaveChatContent = (userName: string) => ({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ text: `${userName} left the chat`, type: 'text' }],
|
||||
},
|
||||
],
|
||||
})
|
||||
export const joinChatContent = (userName: string) => {
|
||||
return {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [{ text: `${userName} joined the chat!`, type: 'text' }],
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
export const insertPrivateMessage = async (
|
||||
content: Json,
|
||||
channelId: number,
|
||||
userId: string,
|
||||
visibility: ChatVisibility,
|
||||
pg: SupabaseDirectClient
|
||||
) => {
|
||||
const lastMessage = await pg.one(
|
||||
`insert into private_user_messages (content, channel_id, user_id, visibility)
|
||||
values ($1, $2, $3, $4) returning created_time`,
|
||||
[content, channelId, userId, visibility]
|
||||
)
|
||||
await pg.none(
|
||||
`update private_user_message_channels set last_updated_time = $1 where id = $2`,
|
||||
[lastMessage.created_time, channelId]
|
||||
)
|
||||
}
|
||||
|
||||
export const addUsersToPrivateMessageChannel = async (
|
||||
userIds: string[],
|
||||
channelId: number,
|
||||
pg: SupabaseDirectClient
|
||||
) => {
|
||||
await Promise.all(
|
||||
userIds.map((id) =>
|
||||
pg.none(
|
||||
`insert into private_user_message_channel_members (channel_id, user_id, role, status)
|
||||
values
|
||||
($1, $2, 'member', 'proposed')
|
||||
on conflict do nothing
|
||||
`,
|
||||
[channelId, id]
|
||||
)
|
||||
)
|
||||
)
|
||||
await pg.none(
|
||||
`update private_user_message_channels set last_updated_time = now() where id = $1`,
|
||||
[channelId]
|
||||
)
|
||||
}
|
||||
|
||||
export const createPrivateUserMessageMain = async (
|
||||
creator: User,
|
||||
channelId: number,
|
||||
content: JSONContent,
|
||||
pg: SupabaseDirectClient,
|
||||
visibility: ChatVisibility
|
||||
) => {
|
||||
// Normally, users can only submit messages to channels that they are members of
|
||||
const authorized = await pg.oneOrNone(
|
||||
`select 1
|
||||
from private_user_message_channel_members
|
||||
where channel_id = $1
|
||||
and user_id = $2`,
|
||||
[channelId, creator.id]
|
||||
)
|
||||
if (!authorized)
|
||||
throw new APIError(403, 'You are not authorized to post to this channel')
|
||||
|
||||
await notifyOtherUserInChannelIfInactive(channelId, creator, pg)
|
||||
await insertPrivateMessage(content, channelId, creator.id, visibility, pg)
|
||||
|
||||
const privateMessage = {
|
||||
content: content as Json,
|
||||
channel_id: channelId,
|
||||
user_id: creator.id,
|
||||
}
|
||||
|
||||
const otherUserIds = await pg.map<string>(
|
||||
`select user_id from private_user_message_channel_members
|
||||
where channel_id = $1 and user_id != $2
|
||||
and status != 'left'
|
||||
`,
|
||||
[channelId, creator.id],
|
||||
(r) => r.user_id
|
||||
)
|
||||
otherUserIds.concat(creator.id).forEach((otherUserId) => {
|
||||
broadcast(`private-user-messages/${otherUserId}`, {})
|
||||
})
|
||||
|
||||
track(creator.id, 'send private message', {
|
||||
channelId,
|
||||
otherUserIds,
|
||||
})
|
||||
|
||||
return privateMessage
|
||||
}
|
||||
|
||||
const notifyOtherUserInChannelIfInactive = async (
|
||||
channelId: number,
|
||||
creator: User,
|
||||
pg: SupabaseDirectClient
|
||||
) => {
|
||||
const otherUserIds = await pg.manyOrNone<{ user_id: string }>(
|
||||
`select user_id from private_user_message_channel_members
|
||||
where channel_id = $1 and user_id != $2
|
||||
and status != 'left'
|
||||
`,
|
||||
[channelId, creator.id]
|
||||
)
|
||||
// We're only sending notifs for 1:1 channels
|
||||
if (!otherUserIds || otherUserIds.length > 1) return
|
||||
|
||||
const otherUserId = first(otherUserIds)
|
||||
if (!otherUserId) return
|
||||
|
||||
const startOfDay = dayjs()
|
||||
.tz('America/Los_Angeles')
|
||||
.startOf('day')
|
||||
.toISOString()
|
||||
const previousMessagesThisDayBetweenTheseUsers = await pg.one(
|
||||
`select count(*) from private_user_messages
|
||||
where channel_id = $1
|
||||
and user_id = $2
|
||||
and created_time > $3
|
||||
`,
|
||||
[channelId, creator.id, startOfDay]
|
||||
)
|
||||
log('previous messages this day', previousMessagesThisDayBetweenTheseUsers)
|
||||
if (previousMessagesThisDayBetweenTheseUsers.count > 0) return
|
||||
|
||||
// TODO: notification only for active user
|
||||
|
||||
const otherUser = await getUser(otherUserId.user_id)
|
||||
if (!otherUser) return
|
||||
|
||||
await createNewMessageNotification(creator, otherUser, channelId)
|
||||
}
|
||||
|
||||
const createNewMessageNotification = async (
|
||||
fromUser: User,
|
||||
toUser: User,
|
||||
channelId: number
|
||||
) => {
|
||||
const privateUser = await getPrivateUser(toUser.id)
|
||||
if (!privateUser) return
|
||||
await sendNewMessageEmail(privateUser, fromUser, toUser, channelId)
|
||||
}
|
||||
43
backend/api/src/leave-private-user-message-channel.ts
Normal file
43
backend/api/src/leave-private-user-message-channel.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { APIError, APIHandler } from 'api/helpers/endpoint'
|
||||
import { log, getUser } from 'shared/utils'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import {
|
||||
insertPrivateMessage,
|
||||
leaveChatContent,
|
||||
} from 'api/junk-drawer/private-messages'
|
||||
|
||||
export const leavePrivateUserMessageChannel: APIHandler<
|
||||
'leave-private-user-message-channel'
|
||||
> = async ({ channelId }, auth) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
const user = await getUser(auth.uid)
|
||||
if (!user) throw new APIError(401, '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')
|
||||
log('membershipStatus: ' + membershipStatus)
|
||||
|
||||
// add message that the user left the channel
|
||||
await pg.none(
|
||||
`
|
||||
update private_user_message_channel_members
|
||||
set status = 'left'
|
||||
where channel_id=$1 and user_id=$2;
|
||||
`,
|
||||
[channelId, auth.uid]
|
||||
)
|
||||
|
||||
await insertPrivateMessage(
|
||||
leaveChatContent(user.name),
|
||||
channelId,
|
||||
auth.uid,
|
||||
'system_status',
|
||||
pg
|
||||
)
|
||||
return { status: 'success', channelId: Number(channelId) }
|
||||
}
|
||||
69
backend/api/src/like-lover.ts
Normal file
69
backend/api/src/like-lover.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { APIError, APIHandler } from './helpers/endpoint'
|
||||
import { createLoveLikeNotification } from 'shared/create-love-notification'
|
||||
import { getHasFreeLike } from './has-free-like'
|
||||
import { log } from 'shared/utils'
|
||||
import { tryCatch } from 'common/util/try-catch'
|
||||
import { Row } from 'common/supabase/utils'
|
||||
|
||||
export const likeLover: APIHandler<'like-lover'> = async (props, auth) => {
|
||||
const { targetUserId, remove } = props
|
||||
const creatorId = auth.uid
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
if (remove) {
|
||||
const { error } = await tryCatch(
|
||||
pg.none(
|
||||
'delete from love_likes where creator_id = $1 and target_id = $2',
|
||||
[creatorId, targetUserId]
|
||||
)
|
||||
)
|
||||
|
||||
if (error) {
|
||||
throw new APIError(500, 'Failed to remove like: ' + error.message)
|
||||
}
|
||||
return { status: 'success' }
|
||||
}
|
||||
|
||||
// Check if like already exists
|
||||
const { data: existing } = await tryCatch(
|
||||
pg.oneOrNone<Row<'love_likes'>>(
|
||||
'select * from love_likes where creator_id = $1 and target_id = $2',
|
||||
[creatorId, targetUserId]
|
||||
)
|
||||
)
|
||||
|
||||
if (existing) {
|
||||
log('Like already exists, do nothing')
|
||||
return { status: 'success' }
|
||||
}
|
||||
|
||||
const hasFreeLike = await getHasFreeLike(creatorId)
|
||||
|
||||
if (!hasFreeLike) {
|
||||
// Charge for like.
|
||||
throw new APIError(403, 'You already liked someone today!')
|
||||
}
|
||||
|
||||
// Insert the new like
|
||||
const { data, error } = await tryCatch(
|
||||
pg.one<Row<'love_likes'>>(
|
||||
'insert into love_likes (creator_id, target_id) values ($1, $2) returning *',
|
||||
[creatorId, targetUserId]
|
||||
)
|
||||
)
|
||||
|
||||
if (error) {
|
||||
throw new APIError(500, 'Failed to add like: ' + error.message)
|
||||
}
|
||||
|
||||
const continuation = async () => {
|
||||
await createLoveLikeNotification(data)
|
||||
}
|
||||
|
||||
return {
|
||||
result: { status: 'success' },
|
||||
continue: continuation,
|
||||
}
|
||||
}
|
||||
16
backend/api/src/mark-all-notifications-read.ts
Normal file
16
backend/api/src/mark-all-notifications-read.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { APIHandler } from './helpers/endpoint'
|
||||
|
||||
export const markAllNotifsRead: APIHandler<'mark-all-notifs-read'> = async (
|
||||
_,
|
||||
auth
|
||||
) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
await pg.none(
|
||||
`update user_notifications
|
||||
SET data = jsonb_set(data, '{isSeen}', 'true'::jsonb)
|
||||
where user_id = $1
|
||||
and data->>'isSeen' = 'false'`,
|
||||
[auth.uid]
|
||||
)
|
||||
}
|
||||
30
backend/api/src/remove-pinned-photo.ts
Normal file
30
backend/api/src/remove-pinned-photo.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { APIError } from 'api/helpers/endpoint'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { type APIHandler } from 'api/helpers/endpoint'
|
||||
import { isAdminId } from 'common/envs/constants'
|
||||
import { log } from 'shared/utils'
|
||||
import { tryCatch } from 'common/util/try-catch'
|
||||
|
||||
export const removePinnedPhoto: APIHandler<'remove-pinned-photo'> = async (
|
||||
body: { userId: string },
|
||||
auth
|
||||
) => {
|
||||
const { userId } = body
|
||||
log('remove pinned url', { userId })
|
||||
|
||||
if (!isAdminId(auth.uid))
|
||||
throw new APIError(403, 'Only admins can remove pinned photo')
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
const { error } = await tryCatch(
|
||||
pg.none('update lovers set pinned_url = null where user_id = $1', [userId])
|
||||
)
|
||||
|
||||
if (error) {
|
||||
throw new APIError(500, 'Failed to remove pinned photo')
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
}
|
||||
}
|
||||
37
backend/api/src/report.ts
Normal file
37
backend/api/src/report.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
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'
|
||||
|
||||
// abusable: people can report the wrong person, that didn't write the comment
|
||||
// but in practice we check it manually and nothing bad happens to them automatically
|
||||
export const report: APIHandler<'report'> = async (body, auth) => {
|
||||
const {
|
||||
contentOwnerId,
|
||||
contentType,
|
||||
contentId,
|
||||
description,
|
||||
parentId,
|
||||
parentType,
|
||||
} = body
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const result = await tryCatch(
|
||||
insert(pg, 'reports', {
|
||||
user_id: auth.uid,
|
||||
content_owner_id: contentOwnerId,
|
||||
content_type: contentType,
|
||||
content_id: contentId,
|
||||
description,
|
||||
parent_id: parentId,
|
||||
parent_type: parentType,
|
||||
})
|
||||
)
|
||||
|
||||
if (result.error) {
|
||||
throw new APIError(500, 'Failed to create report: ' + result.error.message)
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
}
|
||||
36
backend/api/src/search-location.ts
Normal file
36
backend/api/src/search-location.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { APIHandler } from './helpers/endpoint'
|
||||
|
||||
export const searchLocation: APIHandler<'search-location'> = async (body) => {
|
||||
const { term, limit } = body
|
||||
const apiKey = process.env.GEODB_API_KEY
|
||||
console.log('GEODB_API_KEY', apiKey)
|
||||
|
||||
if (!apiKey) {
|
||||
return { status: 'failure', data: 'Missing GEODB API key' }
|
||||
}
|
||||
const host = 'wft-geo-db.p.rapidapi.com'
|
||||
const baseUrl = `https://${host}/v1/geo`
|
||||
const url = `${baseUrl}/cities?namePrefix=${term}&limit=${
|
||||
limit ?? 10
|
||||
}&offset=0&sort=-population`
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-RapidAPI-Key': apiKey,
|
||||
'X-RapidAPI-Host': host,
|
||||
},
|
||||
})
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! Status: ${res.status} ${await res.text()}`)
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
// console.log('GEO DB', data)
|
||||
return { status: 'success', data: data }
|
||||
} catch (error: any) {
|
||||
console.log('failure', error)
|
||||
return { status: 'failure', data: error.message }
|
||||
}
|
||||
}
|
||||
45
backend/api/src/search-near-city.ts
Normal file
45
backend/api/src/search-near-city.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { APIHandler } from './helpers/endpoint'
|
||||
|
||||
export const searchNearCity: APIHandler<'search-near-city'> = async (body) => {
|
||||
const { cityId, radius } = body
|
||||
return await searchNearCityMain(cityId, radius)
|
||||
}
|
||||
|
||||
const searchNearCityMain = async (cityId: string, radius: number) => {
|
||||
const apiKey = process.env.GEODB_API_KEY
|
||||
|
||||
if (!apiKey) {
|
||||
return { status: 'failure', data: 'Missing GEODB API key' }
|
||||
}
|
||||
const host = 'wft-geo-db.p.rapidapi.com'
|
||||
const baseUrl = `https://${host}/v1/geo`
|
||||
const url = `${baseUrl}/cities/${cityId}/nearbyCities?radius=${radius}&offset=0&sort=-population&limit=100`
|
||||
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-RapidAPI-Key': apiKey,
|
||||
'X-RapidAPI-Host': host,
|
||||
},
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`HTTP error! Status: ${res.status}`)
|
||||
}
|
||||
|
||||
const data = await res.json()
|
||||
|
||||
return { status: 'success', data: data }
|
||||
} catch (error) {
|
||||
return { status: 'failure', data: error }
|
||||
}
|
||||
}
|
||||
|
||||
export const getNearbyCities = async (cityId: string, radius: number) => {
|
||||
const result = await searchNearCityMain(cityId, radius)
|
||||
const cityIds = (result.data.data as any[]).map(
|
||||
(city) => city.id.toString() as string
|
||||
)
|
||||
return cityIds
|
||||
}
|
||||
70
backend/api/src/search-users.ts
Normal file
70
backend/api/src/search-users.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { constructPrefixTsQuery } from 'shared/helpers/search'
|
||||
import {
|
||||
from,
|
||||
join,
|
||||
limit,
|
||||
orderBy,
|
||||
renderSql,
|
||||
select,
|
||||
where,
|
||||
} from 'shared/supabase/sql-builder'
|
||||
import { type APIHandler } from './helpers/endpoint'
|
||||
import { convertUser } from 'common/supabase/users'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { toUserAPIResponse } from 'common/api/user-types'
|
||||
import { uniqBy } from 'lodash'
|
||||
|
||||
export const searchUsers: APIHandler<'search-users'> = async (props, auth) => {
|
||||
const { term, page, limit } = props
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const offset = page * limit
|
||||
const userId = auth?.uid
|
||||
const searchFollowersSQL = getSearchUserSQL({ term, offset, limit, userId })
|
||||
const searchAllSQL = getSearchUserSQL({ term, offset, limit })
|
||||
const [followers, all] = await Promise.all([
|
||||
pg.map(searchFollowersSQL, null, convertUser),
|
||||
pg.map(searchAllSQL, null, convertUser),
|
||||
])
|
||||
|
||||
return uniqBy([...followers, ...all], 'id')
|
||||
.map(toUserAPIResponse)
|
||||
.slice(0, limit)
|
||||
}
|
||||
|
||||
function getSearchUserSQL(props: {
|
||||
term: string
|
||||
offset: number
|
||||
limit: number
|
||||
userId?: string // search only this user's followers
|
||||
}) {
|
||||
const { term, userId } = props
|
||||
|
||||
return renderSql(
|
||||
userId
|
||||
? [
|
||||
select('users.*'),
|
||||
from('users'),
|
||||
join('user_follows on user_follows.follow_id = users.id'),
|
||||
where('user_follows.user_id = $1', [userId]),
|
||||
]
|
||||
: [select('*'), from('users')],
|
||||
term
|
||||
? [
|
||||
where(
|
||||
`name_username_vector @@ websearch_to_tsquery('english', $1)
|
||||
or name_username_vector @@ to_tsquery('english', $2)`,
|
||||
[term, constructPrefixTsQuery(term)]
|
||||
),
|
||||
|
||||
orderBy(
|
||||
`ts_rank(name_username_vector, websearch_to_tsquery($1)) desc,
|
||||
data->>'lastBetTime' desc nulls last`,
|
||||
[term]
|
||||
),
|
||||
]
|
||||
: orderBy(`data->'creatorTraders'->'allTime' desc nulls last`),
|
||||
limit(props.limit, props.offset)
|
||||
)
|
||||
}
|
||||
41
backend/api/src/serve.ts
Normal file
41
backend/api/src/serve.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import * as admin from 'firebase-admin'
|
||||
import { getLocalEnv, initAdmin } from 'shared/init-admin'
|
||||
import { loadSecretsToEnv, getServiceAccountCredentials } from 'common/secrets'
|
||||
import { LOCAL_DEV, log } from 'shared/utils'
|
||||
import { METRIC_WRITER } from 'shared/monitoring/metric-writer'
|
||||
import { listen as webSocketListen } from 'shared/websockets/server'
|
||||
|
||||
log('Api server starting up....')
|
||||
|
||||
if (LOCAL_DEV) {
|
||||
initAdmin()
|
||||
} else {
|
||||
const projectId = process.env.GOOGLE_CLOUD_PROJECT
|
||||
admin.initializeApp({
|
||||
projectId,
|
||||
storageBucket: `${projectId}.appspot.com`,
|
||||
})
|
||||
}
|
||||
|
||||
METRIC_WRITER.start()
|
||||
|
||||
import { app } from './app'
|
||||
|
||||
const credentials = LOCAL_DEV
|
||||
? getServiceAccountCredentials(getLocalEnv())
|
||||
: // No explicit credentials needed for deployed service.
|
||||
undefined
|
||||
|
||||
const startupProcess = async () => {
|
||||
await loadSecretsToEnv(credentials)
|
||||
log('Secrets loaded.')
|
||||
|
||||
const PORT = process.env.PORT ?? 8088
|
||||
const httpServer = app.listen(PORT, () => {
|
||||
log.info(`Serving API on port ${PORT}.`)
|
||||
})
|
||||
|
||||
webSocketListen(httpServer, '/ws')
|
||||
log('Server started successfully')
|
||||
}
|
||||
startupProcess()
|
||||
73
backend/api/src/ship-lovers.ts
Normal file
73
backend/api/src/ship-lovers.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { APIError, APIHandler } from './helpers/endpoint'
|
||||
import { createLoveShipNotification } from 'shared/create-love-notification'
|
||||
import { log } from 'shared/utils'
|
||||
import { tryCatch } from 'common/util/try-catch'
|
||||
import { insert } from 'shared/supabase/utils'
|
||||
|
||||
export const shipLovers: APIHandler<'ship-lovers'> = async (props, auth) => {
|
||||
const { targetUserId1, targetUserId2, remove } = props
|
||||
const creatorId = auth.uid
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
// Check if ship already exists or with swapped target IDs
|
||||
const existing = await tryCatch(
|
||||
pg.oneOrNone<{ ship_id: string }>(
|
||||
`select ship_id from love_ships
|
||||
where creator_id = $1
|
||||
and (
|
||||
target1_id = $2 and target2_id = $3
|
||||
or target1_id = $3 and target2_id = $2
|
||||
)`,
|
||||
[creatorId, targetUserId1, targetUserId2]
|
||||
)
|
||||
)
|
||||
|
||||
if (existing.error)
|
||||
throw new APIError(
|
||||
500,
|
||||
'Error when checking ship: ' + existing.error.message
|
||||
)
|
||||
|
||||
if (existing.data) {
|
||||
if (remove) {
|
||||
const { error } = await tryCatch(
|
||||
pg.none('delete from love_ships where ship_id = $1', [
|
||||
existing.data.ship_id,
|
||||
])
|
||||
)
|
||||
if (error) {
|
||||
throw new APIError(500, 'Failed to remove ship: ' + error.message)
|
||||
}
|
||||
} else {
|
||||
log('Ship already exists, do nothing')
|
||||
}
|
||||
return { status: 'success' }
|
||||
}
|
||||
|
||||
// Insert the new ship
|
||||
const { data, error } = await tryCatch(
|
||||
insert(pg, 'love_ships', {
|
||||
creator_id: creatorId,
|
||||
target1_id: targetUserId1,
|
||||
target2_id: targetUserId2,
|
||||
})
|
||||
)
|
||||
|
||||
if (error) {
|
||||
throw new APIError(500, 'Failed to create ship: ' + error.message)
|
||||
}
|
||||
|
||||
const continuation = async () => {
|
||||
await Promise.all([
|
||||
createLoveShipNotification(data, data.target1_id),
|
||||
createLoveShipNotification(data, data.target2_id),
|
||||
])
|
||||
}
|
||||
|
||||
return {
|
||||
result: { status: 'success' },
|
||||
continue: continuation,
|
||||
}
|
||||
}
|
||||
51
backend/api/src/star-lover.ts
Normal file
51
backend/api/src/star-lover.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { APIError, APIHandler } from './helpers/endpoint'
|
||||
import { log } from 'shared/utils'
|
||||
import { tryCatch } from 'common/util/try-catch'
|
||||
import { Row } from 'common/supabase/utils'
|
||||
import { insert } from 'shared/supabase/utils'
|
||||
|
||||
export const starLover: APIHandler<'star-lover'> = async (props, auth) => {
|
||||
const { targetUserId, remove } = props
|
||||
const creatorId = auth.uid
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
if (remove) {
|
||||
const { error } = await tryCatch(
|
||||
pg.none(
|
||||
'delete from love_stars where creator_id = $1 and target_id = $2',
|
||||
[creatorId, targetUserId]
|
||||
)
|
||||
)
|
||||
|
||||
if (error) {
|
||||
throw new APIError(500, 'Failed to remove star: ' + error.message)
|
||||
}
|
||||
return { status: 'success' }
|
||||
}
|
||||
|
||||
// Check if star already exists
|
||||
const { data: existing } = await tryCatch(
|
||||
pg.oneOrNone<Row<'love_stars'>>(
|
||||
'select * from love_stars where creator_id = $1 and target_id = $2',
|
||||
[creatorId, targetUserId]
|
||||
)
|
||||
)
|
||||
|
||||
if (existing) {
|
||||
log('star already exists, do nothing')
|
||||
return { status: 'success' }
|
||||
}
|
||||
|
||||
// Insert the new star
|
||||
const { error } = await tryCatch(
|
||||
insert(pg, 'love_stars', { creator_id: creatorId, target_id: targetUserId })
|
||||
)
|
||||
|
||||
if (error) {
|
||||
throw new APIError(500, 'Failed to add star: ' + error.message)
|
||||
}
|
||||
|
||||
return { status: 'success' }
|
||||
}
|
||||
45
backend/api/src/update-lover.ts
Normal file
45
backend/api/src/update-lover.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { APIError, APIHandler } from 'api/helpers/endpoint'
|
||||
import { removePinnedUrlFromPhotoUrls } from 'shared/love/parse-photos'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { updateUser } from 'shared/supabase/users'
|
||||
import { log } from 'shared/utils'
|
||||
import { tryCatch } from 'common/util/try-catch'
|
||||
import { update } from 'shared/supabase/utils'
|
||||
import { type Row } from 'common/supabase/utils'
|
||||
|
||||
export const updateLover: APIHandler<'update-lover'> = async (
|
||||
parsedBody,
|
||||
auth
|
||||
) => {
|
||||
log('parsedBody', parsedBody)
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const { data: existingLover } = await tryCatch(
|
||||
pg.oneOrNone<Row<'lovers'>>('select * from lovers where user_id = $1', [
|
||||
auth.uid,
|
||||
])
|
||||
)
|
||||
|
||||
if (!existingLover) {
|
||||
throw new APIError(404, 'Lover not found')
|
||||
}
|
||||
|
||||
!parsedBody.last_online_time &&
|
||||
log('Updating lover', { userId: auth.uid, parsedBody })
|
||||
|
||||
await removePinnedUrlFromPhotoUrls(parsedBody)
|
||||
if (parsedBody.avatar_url) {
|
||||
await updateUser(pg, auth.uid, { avatarUrl: parsedBody.avatar_url })
|
||||
}
|
||||
|
||||
const { data, error } = await tryCatch(
|
||||
update(pg, 'lovers', 'user_id', { user_id: auth.uid, ...parsedBody })
|
||||
)
|
||||
|
||||
if (error) {
|
||||
log('Error updating lover', error)
|
||||
throw new APIError(500, 'Error updating lover')
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
101
backend/api/src/update-me.ts
Normal file
101
backend/api/src/update-me.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { toUserAPIResponse } from 'common/api/user-types'
|
||||
import { RESERVED_PATHS } from 'common/envs/constants'
|
||||
import { cleanDisplayName, cleanUsername } from 'common/util/clean-username'
|
||||
import { removeUndefinedProps } from 'common/util/object'
|
||||
import { cloneDeep, mapValues } from 'lodash'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { getUser, getUserByUsername } from 'shared/utils'
|
||||
import { APIError, APIHandler } from './helpers/endpoint'
|
||||
import { updateUser } from 'shared/supabase/users'
|
||||
import { broadcastUpdatedUser } from 'shared/websockets/helpers'
|
||||
import { strip } from 'common/socials'
|
||||
|
||||
export const updateMe: APIHandler<'me/update'> = async (props, auth) => {
|
||||
const update = cloneDeep(props)
|
||||
|
||||
const user = await getUser(auth.uid)
|
||||
if (!user) throw new APIError(401, 'Your account was not found')
|
||||
|
||||
if (update.name) {
|
||||
update.name = cleanDisplayName(update.name)
|
||||
}
|
||||
|
||||
if (update.username) {
|
||||
const cleanedUsername = cleanUsername(update.username)
|
||||
if (!cleanedUsername) throw new APIError(400, 'Invalid username')
|
||||
const reservedName = RESERVED_PATHS.includes(cleanedUsername)
|
||||
if (reservedName) throw new APIError(403, 'This username is reserved')
|
||||
const otherUserExists = await getUserByUsername(cleanedUsername)
|
||||
if (otherUserExists) throw new APIError(403, 'Username already taken')
|
||||
update.username = cleanedUsername
|
||||
}
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
|
||||
const { name, username, avatarUrl, link = {}, ...rest } = update
|
||||
await updateUser(pg, auth.uid, removeUndefinedProps(rest))
|
||||
|
||||
if (update.website != undefined) link.site = update.website
|
||||
if (update.twitterHandle != undefined) link.x = update.twitterHandle
|
||||
if (update.discordHandle != undefined) link.discord = update.discordHandle
|
||||
|
||||
const stripped = mapValues(
|
||||
link,
|
||||
(value, site) => value && strip(site as any, value)
|
||||
)
|
||||
|
||||
const adds = {} as { [key: string]: string }
|
||||
const removes = []
|
||||
for (const [key, value] of Object.entries(stripped)) {
|
||||
if (value === null || value === '') {
|
||||
removes.push(key)
|
||||
} else if (value) {
|
||||
adds[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
let newLinks: any = null
|
||||
if (Object.keys(adds).length > 0 || removes.length > 0) {
|
||||
const data = await pg.oneOrNone(
|
||||
`update users
|
||||
set data = jsonb_set(
|
||||
data, '{link}',
|
||||
(data->'link' || $(adds)) - $(removes)
|
||||
)
|
||||
where id = $(id)
|
||||
returning data->'link' as link`,
|
||||
{ adds, removes, id: auth.uid }
|
||||
)
|
||||
newLinks = data?.link
|
||||
}
|
||||
|
||||
if (name || username || avatarUrl) {
|
||||
if (name) {
|
||||
await pg.none(`update users set name = $1 where id = $2`, [
|
||||
name,
|
||||
auth.uid,
|
||||
])
|
||||
}
|
||||
if (username) {
|
||||
await pg.none(`update users set username = $1 where id = $2`, [
|
||||
username,
|
||||
auth.uid,
|
||||
])
|
||||
}
|
||||
if (avatarUrl) {
|
||||
await updateUser(pg, auth.uid, { avatarUrl })
|
||||
}
|
||||
|
||||
broadcastUpdatedUser(
|
||||
removeUndefinedProps({
|
||||
id: auth.uid,
|
||||
name,
|
||||
username,
|
||||
avatarUrl,
|
||||
link: newLinks ?? undefined,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return toUserAPIResponse({ ...user, ...update, link: newLinks })
|
||||
}
|
||||
28
backend/api/src/update-notif-setting.ts
Normal file
28
backend/api/src/update-notif-setting.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { updatePrivateUser } from 'shared/supabase/users'
|
||||
import { type APIHandler } from './helpers/endpoint'
|
||||
import { broadcastUpdatedPrivateUser } from 'shared/websockets/helpers'
|
||||
|
||||
export const updateNotifSettings: APIHandler<'update-notif-settings'> = async (
|
||||
{ type, medium, enabled },
|
||||
auth
|
||||
) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
if (type === 'opt_out_all' && medium === 'mobile') {
|
||||
await updatePrivateUser(pg, auth.uid, {
|
||||
interestedInPushNotifications: !enabled,
|
||||
})
|
||||
} else {
|
||||
// deep update array at data.notificationPreferences[type]
|
||||
await pg.none(
|
||||
`update private_users
|
||||
set data = jsonb_set(data, '{notificationPreferences, $1:raw}',
|
||||
coalesce(data->'notificationPreferences'->$1, '[]'::jsonb)
|
||||
${enabled ? `|| '[$2:name]'::jsonb` : `- $2`}
|
||||
)
|
||||
where id = $3`,
|
||||
[type, medium, auth.uid]
|
||||
)
|
||||
broadcastUpdatedPrivateUser(auth.uid)
|
||||
}
|
||||
}
|
||||
33
backend/api/src/update-private-user-message-channel.ts
Normal file
33
backend/api/src/update-private-user-message-channel.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { APIError, APIHandler } from 'api/helpers/endpoint'
|
||||
import { log, getUser } from 'shared/utils'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { millisToTs } from 'common/supabase/utils'
|
||||
|
||||
export const updatePrivateUserMessageChannel: APIHandler<
|
||||
'update-private-user-message-channel'
|
||||
> = async (body, auth) => {
|
||||
const { channelId, notifyAfterTime } = body
|
||||
const pg = createSupabaseDirectClient()
|
||||
const user = await getUser(auth.uid)
|
||||
if (!user) throw new APIError(401, '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 this channel')
|
||||
log('membershipStatus ' + membershipStatus)
|
||||
|
||||
await pg.none(
|
||||
`
|
||||
update private_user_message_channel_members
|
||||
set notify_after_time = $3
|
||||
where channel_id=$1 and user_id=$2;
|
||||
`,
|
||||
[channelId, auth.uid, millisToTs(notifyAfterTime)]
|
||||
)
|
||||
|
||||
return { status: 'success', channelId: Number(channelId) }
|
||||
}
|
||||
32
backend/api/tsconfig.json
Normal file
32
backend/api/tsconfig.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"composite": true,
|
||||
"module": "commonjs",
|
||||
"noImplicitReturns": true,
|
||||
"outDir": "./lib",
|
||||
"tsBuildInfoFile": "lib/tsconfig.tsbuildinfo",
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"target": "esnext",
|
||||
"skipLibCheck": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"common/*": ["../../common/src/*", "../../../common/lib/*"],
|
||||
"shared/*": ["../shared/src/*", "../../shared/lib/*"],
|
||||
"email/*": ["../email/emails/*", "../../email/lib/*"],
|
||||
"api/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"ts-node": {
|
||||
"require": ["tsconfig-paths/register"]
|
||||
},
|
||||
"references": [
|
||||
{ "path": "../../common" },
|
||||
{ "path": "../shared" },
|
||||
{ "path": "../email" }
|
||||
],
|
||||
"compileOnSave": true,
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
23
backend/api/url-map-config.yaml
Normal file
23
backend/api/url-map-config.yaml
Normal file
@@ -0,0 +1,23 @@
|
||||
name: aou-kb
|
||||
defaultService: global/backendServices/api-lb-service-0
|
||||
hostRules:
|
||||
- hosts:
|
||||
- '*'
|
||||
pathMatcher: matcher-1
|
||||
pathMatchers:
|
||||
- name: matcher-1
|
||||
defaultService: global/backendServices/api-lb-service-0
|
||||
routeRules:
|
||||
- priority: 1
|
||||
routeAction:
|
||||
weightedBackendServices:
|
||||
- backendService: global/backendServices/api-lb-service-0
|
||||
weight: 25
|
||||
- backendService: global/backendServices/api-lb-service-1
|
||||
weight: 25
|
||||
- backendService: global/backendServices/api-lb-service-2
|
||||
weight: 25
|
||||
- backendService: global/backendServices/api-lb-service-3
|
||||
weight: 25
|
||||
# redeploy this by running:
|
||||
# gcloud compute url-maps import aou-kb --source=url-map-config.yaml --project polylove --global
|
||||
10
backend/email/.gitignore
vendored
Normal file
10
backend/email/.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# Compiled JavaScript files
|
||||
lib/
|
||||
|
||||
# TypeScript v1 declaration files
|
||||
typings/
|
||||
|
||||
# Node.js dependency directory
|
||||
node_modules/
|
||||
|
||||
package-lock.json
|
||||
108
backend/email/emails/functions/helpers.tsx
Normal file
108
backend/email/emails/functions/helpers.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { PrivateUser, User } from 'common/user'
|
||||
import { getNotificationDestinationsForUser } from 'common/user-notification-preferences'
|
||||
import { sendEmail } from './send-email'
|
||||
import { NewMatchEmail } from '../new-match'
|
||||
import { NewMessageEmail } from '../new-message'
|
||||
import { NewEndorsementEmail } from '../new-endorsement'
|
||||
import { Test } from '../test'
|
||||
import { getLover } from 'shared/love/supabase'
|
||||
|
||||
const from = 'Love <no-reply@poly.love>'
|
||||
|
||||
export const sendNewMatchEmail = async (
|
||||
privateUser: PrivateUser,
|
||||
matchedWithUser: User
|
||||
) => {
|
||||
const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
'new_match'
|
||||
)
|
||||
if (!privateUser.email || !sendToEmail) return
|
||||
const lover = await getLover(privateUser.id)
|
||||
if (!lover) return
|
||||
|
||||
return await sendEmail({
|
||||
from,
|
||||
subject: `You have a new match!`,
|
||||
to: privateUser.email,
|
||||
react: (
|
||||
<NewMatchEmail
|
||||
onUser={lover.user}
|
||||
matchedWithUser={matchedWithUser}
|
||||
matchedLover={lover}
|
||||
unsubscribeUrl={unsubscribeUrl}
|
||||
/>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
export const sendNewMessageEmail = async (
|
||||
privateUser: PrivateUser,
|
||||
fromUser: User,
|
||||
toUser: User,
|
||||
channelId: number
|
||||
) => {
|
||||
const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
'new_message'
|
||||
)
|
||||
if (!privateUser.email || !sendToEmail) return
|
||||
|
||||
const lover = await getLover(fromUser.id)
|
||||
|
||||
if (!lover) {
|
||||
console.error('Could not send email notification: Lover not found')
|
||||
return
|
||||
}
|
||||
|
||||
return await sendEmail({
|
||||
from,
|
||||
subject: `${fromUser.name} sent you a message!`,
|
||||
to: privateUser.email,
|
||||
react: (
|
||||
<NewMessageEmail
|
||||
fromUser={fromUser}
|
||||
fromUserLover={lover}
|
||||
toUser={toUser}
|
||||
channelId={channelId}
|
||||
unsubscribeUrl={unsubscribeUrl}
|
||||
/>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
export const sendNewEndorsementEmail = async (
|
||||
privateUser: PrivateUser,
|
||||
fromUser: User,
|
||||
onUser: User,
|
||||
text: string
|
||||
) => {
|
||||
const { sendToEmail, unsubscribeUrl } = getNotificationDestinationsForUser(
|
||||
privateUser,
|
||||
'new_endorsement'
|
||||
)
|
||||
if (!privateUser.email || !sendToEmail) return
|
||||
|
||||
return await sendEmail({
|
||||
from,
|
||||
subject: `${fromUser.name} just endorsed you!`,
|
||||
to: privateUser.email,
|
||||
react: (
|
||||
<NewEndorsementEmail
|
||||
fromUser={fromUser}
|
||||
onUser={onUser}
|
||||
endorsementText={text}
|
||||
unsubscribeUrl={unsubscribeUrl}
|
||||
/>
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
export const sendTestEmail = async (toEmail: string) => {
|
||||
return await sendEmail({
|
||||
from,
|
||||
subject: 'Test email from Love',
|
||||
to: toEmail,
|
||||
react: <Test name="Test User" />,
|
||||
})
|
||||
}
|
||||
205
backend/email/emails/functions/mock.ts
Normal file
205
backend/email/emails/functions/mock.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { LoverRow } from 'common/love/lover'
|
||||
import type { User } from 'common/user'
|
||||
|
||||
// for email template testing
|
||||
|
||||
export const sinclairUser: User = {
|
||||
createdTime: 0,
|
||||
bio: 'the futa in futarchy',
|
||||
website: 'sincl.ai',
|
||||
avatarUrl:
|
||||
'https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FSinclair%2FbqSXdzkn1z.JPG?alt=media&token=7779230a-9f5d-42b5-839f-fbdfef31a3ac',
|
||||
idVerified: true,
|
||||
// fromManifold: true,
|
||||
discordHandle: 'sinclaether#5570',
|
||||
twitterHandle: 'singularitttt',
|
||||
verifiedPhone: true,
|
||||
// sweepstakesVerified: true,
|
||||
id: '0k1suGSJKVUnHbCPEhHNpgZPkUP2',
|
||||
username: 'Sinclair',
|
||||
name: 'Sinclair',
|
||||
// url: 'https://manifold.love/Sinclair',
|
||||
// isAdmin: true,
|
||||
// isTrustworthy: false,
|
||||
link: {
|
||||
site: 'sincl.ai',
|
||||
x: 'singularitttt',
|
||||
discord: 'sinclaether#5570',
|
||||
},
|
||||
}
|
||||
|
||||
export const sinclairLover: LoverRow = {
|
||||
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',
|
||||
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'],
|
||||
wants_kids_strength: 3,
|
||||
looking_for_matches: true,
|
||||
visibility: 'public',
|
||||
messaging_status: 'open',
|
||||
comments_enabled: true,
|
||||
has_kids: 0,
|
||||
is_smoker: false,
|
||||
drinks_per_month: 0,
|
||||
is_vegetarian_or_vegan: null,
|
||||
political_beliefs: ['e/acc', 'libertarian'],
|
||||
religious_belief_strength: null,
|
||||
religious_beliefs: null,
|
||||
photo_urls: [
|
||||
'https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FSinclair%2Flove-images%2FnJz22lr3Bl.jpg?alt=media&token=f1e99ba3-39cc-4637-8702-16a3a8dd49db',
|
||||
'https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FSinclair%2Flove-images%2FygM0mGgP_j.HEIC?alt=media&token=573b23d9-693c-4d6e-919b-097309f370e1',
|
||||
'https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FSinclair%2Flove-images%2FWPZNKxjHGV.HEIC?alt=media&token=190625e1-2cf0-49a6-824b-09b6f4002f2a',
|
||||
'https://firebasestorage.googleapis.com/v0/b/polylove.firebasestorage.app/o/user-images%2FSinclair%2Flove-images%2FlVFKKoLHyV.jpg?alt=media&token=ecb3a003-3672-4382-9ba0-ca894247bb3f',
|
||||
'https://firebasestorage.googleapis.com/v0/b/polylove.firebasestorage.app/o/user-images%2FSinclair%2Flove-images%2Fh659K0bmd4.jpg?alt=media&token=6561ed05-0e2d-4f31-95ee-c7c1c0b33ea6',
|
||||
'https://firebasestorage.googleapis.com/v0/b/polylove.firebasestorage.app/o/user-images%2FSinclair%2Flove-images%2F5OMTo5rhB-.jpg?alt=media&token=4aba4e5a-5115-4d2e-9d57-1e6162e15708',
|
||||
'https://firebasestorage.googleapis.com/v0/b/polylove.firebasestorage.app/o/user-images%2FSinclair%2Flove-images%2FwCT-Y-bgpc.jpg?alt=media&token=91994528-e436-4055-af69-421fa9e29e5c',
|
||||
],
|
||||
pinned_url:
|
||||
'https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FSinclair%2Flove-images%2FYXD19m12D7.jpg?alt=media&token=6cb095b4-dfc8-4bc9-ae67-6f12f29be0a5',
|
||||
ethnicity: ['asian'],
|
||||
born_in_location: null,
|
||||
height_in_inches: 70,
|
||||
education_level: 'bachelors',
|
||||
university: 'Santa Clara University',
|
||||
occupation: null,
|
||||
occupation_title: 'Founding Engineer',
|
||||
company: 'Manifold Markets',
|
||||
website: 'sincl.ai',
|
||||
twitter: 'x.com/singularitttt',
|
||||
region_code: 'CA',
|
||||
country: 'United States of America',
|
||||
city_latitude: 37.7775,
|
||||
city_longitude: -122.416389,
|
||||
geodb_city_id: '126964',
|
||||
referred_by_username: null,
|
||||
bio: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
marks: [
|
||||
{
|
||||
type: 'link',
|
||||
attrs: {
|
||||
href: 'https://sinclaaair.notion.site/Date-Me-487ef432c1f54938bf5e7a45ef05d57b',
|
||||
target: '_blank',
|
||||
},
|
||||
},
|
||||
],
|
||||
text: 'https://sinclaaair.notion.site/Date-Me-487ef432c1f54938bf5e7a45ef05d57b',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
age: 25,
|
||||
}
|
||||
|
||||
export const jamesUser: User = {
|
||||
createdTime: 0,
|
||||
bio: 'Manifold cofounder! We got the AMM (What!?). We got the order book (What!?). We got the combination AMM and order book!',
|
||||
website: 'https://calendly.com/jamesgrugett/manifold',
|
||||
avatarUrl:
|
||||
'https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FJamesGrugett%2FefVzXKc9iz.png?alt=media&token=5c205402-04d5-4e64-be65-9d8b4836eb03',
|
||||
idVerified: true,
|
||||
// fromManifold: true,
|
||||
discordHandle: '',
|
||||
twitterHandle: 'jahooma',
|
||||
verifiedPhone: true,
|
||||
// sweepstakesVerified: true,
|
||||
id: '5LZ4LgYuySdL1huCWe7bti02ghx2',
|
||||
username: 'JamesGrugett',
|
||||
name: 'James',
|
||||
link: {
|
||||
x: 'jahooma',
|
||||
discord: '',
|
||||
},
|
||||
}
|
||||
|
||||
export const jamesLover: LoverRow = {
|
||||
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',
|
||||
city: 'San Francisco',
|
||||
gender: 'male',
|
||||
pref_gender: ['female'],
|
||||
pref_age_min: 22,
|
||||
pref_age_max: 32,
|
||||
pref_relation_styles: ['mono'],
|
||||
wants_kids_strength: 4,
|
||||
looking_for_matches: true,
|
||||
visibility: 'public',
|
||||
messaging_status: 'open',
|
||||
comments_enabled: true,
|
||||
has_kids: 0,
|
||||
is_smoker: false,
|
||||
drinks_per_month: 5,
|
||||
is_vegetarian_or_vegan: null,
|
||||
political_beliefs: ['libertarian'],
|
||||
religious_belief_strength: null,
|
||||
religious_beliefs: '',
|
||||
photo_urls: [
|
||||
'https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FJamesGrugett%2Flove-images%2FKl0WtbZsZW.jpg?alt=media&token=c928604f-e5ff-4406-a229-152864a4aa48',
|
||||
'https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FJamesGrugett%2Flove-images%2Fsii17zOItz.jpg?alt=media&token=474034b9-0d23-4005-97ad-5864abfd85fe',
|
||||
'https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FJamesGrugett%2Flove-images%2F3ICeb-0mwB.jpg?alt=media&token=975dbdb9-5547-4553-b504-e6545eb82ec0',
|
||||
'https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FJamesGrugett%2Flove-images%2FdtuSGk_13Q.jpg?alt=media&token=98191d86-9d10-4571-879c-d00ab9cab09e',
|
||||
],
|
||||
pinned_url:
|
||||
'https://firebasestorage.googleapis.com/v0/b/mantic-markets.appspot.com/o/user-images%2FJamesGrugett%2Flove-images%2FXkLhuxZoOX.jpg?alt=media&token=7f2304dd-bace-4806-8e3c-78c35e57287c',
|
||||
ethnicity: ['caucasian'],
|
||||
born_in_location: 'Melbourne, FL',
|
||||
height_in_inches: 70,
|
||||
education_level: 'bachelors',
|
||||
university: 'Carnegie Mellon',
|
||||
occupation: 'Entrepreneur',
|
||||
occupation_title: 'CEO',
|
||||
company: 'Codebuff',
|
||||
website: 'https://jamesgrugett.com/',
|
||||
twitter: 'https://twitter.com/jahooma',
|
||||
region_code: 'CA',
|
||||
country: 'United States of America',
|
||||
city_latitude: 37.7775,
|
||||
city_longitude: -122.416389,
|
||||
geodb_city_id: '126964',
|
||||
referred_by_username: null,
|
||||
bio: {
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: "Optimist that's working to improve the world!",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'I like outdoor activities, hanging out with my housemates, strategy board games, libertarian and utilitarian ideas, getting boba, and riding my electric unicycle. I also enjoy working hard on bold new initiatives with huge potential for value creation!',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'paragraph',
|
||||
},
|
||||
],
|
||||
},
|
||||
age: 32,
|
||||
}
|
||||
40
backend/email/emails/functions/send-email.ts
Normal file
40
backend/email/emails/functions/send-email.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
CreateEmailRequestOptions,
|
||||
Resend,
|
||||
type CreateEmailOptions,
|
||||
} from 'resend'
|
||||
import { log } from 'shared/utils'
|
||||
|
||||
/*
|
||||
* typically: { subject: string, to: string | string[] } & ({ text: string } | { react: ReactNode })
|
||||
*/
|
||||
export const sendEmail = async (
|
||||
payload: CreateEmailOptions,
|
||||
options?: CreateEmailRequestOptions
|
||||
) => {
|
||||
const resend = getResend()
|
||||
const { data, error } = await resend.emails.send(
|
||||
{ replyTo: 'love@sincl.ai', ...payload },
|
||||
options
|
||||
)
|
||||
|
||||
if (error) {
|
||||
log.error(
|
||||
`Failed to send email to ${payload.to} with subject ${payload.subject}`
|
||||
)
|
||||
log.error(error)
|
||||
return null
|
||||
}
|
||||
|
||||
log(`Sent email to ${payload.to} with subject ${payload.subject}`)
|
||||
return data
|
||||
}
|
||||
|
||||
let resend: Resend | null = null
|
||||
const getResend = () => {
|
||||
if (resend) return resend
|
||||
|
||||
const apiKey = process.env.RESEND_KEY as string
|
||||
resend = new Resend(apiKey)
|
||||
return resend
|
||||
}
|
||||
14
backend/email/emails/functions/send-test-email.ts
Executable file
14
backend/email/emails/functions/send-test-email.ts
Executable file
@@ -0,0 +1,14 @@
|
||||
import { sendTestEmail } from './helpers'
|
||||
|
||||
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')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
sendTestEmail(email)
|
||||
.then(() => console.log('Email sent successfully!'))
|
||||
.catch((error) => console.error('Failed to send email:', error))
|
||||
}
|
||||
180
backend/email/emails/new-endorsement.tsx
Normal file
180
backend/email/emails/new-endorsement.tsx
Normal file
@@ -0,0 +1,180 @@
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Column,
|
||||
Head,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
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'
|
||||
|
||||
interface NewEndorsementEmailProps {
|
||||
fromUser: User
|
||||
onUser: User
|
||||
endorsementText: string
|
||||
unsubscribeUrl: string
|
||||
}
|
||||
|
||||
export const NewEndorsementEmail = ({
|
||||
fromUser,
|
||||
onUser,
|
||||
endorsementText,
|
||||
unsubscribeUrl,
|
||||
}: NewEndorsementEmailProps) => {
|
||||
const name = onUser.name.split(' ')[0]
|
||||
|
||||
const endorsementUrl = `https://${DOMAIN}/${onUser.username}`
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>New endorsement from {fromUser.name}</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Section style={logoContainer}>
|
||||
<Img
|
||||
src="https://manifold.love/manifold-love-banner.png"
|
||||
width="550"
|
||||
height="auto"
|
||||
alt="manifold.love"
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>Hi {name},</Text>
|
||||
|
||||
<Text style={paragraph}>{fromUser.name} just endorsed you!</Text>
|
||||
|
||||
<Section style={endorsementContainer}>
|
||||
<Row>
|
||||
<Column>
|
||||
<Img
|
||||
src={fromUser.avatarUrl}
|
||||
width="50"
|
||||
height="50"
|
||||
alt=""
|
||||
style={avatarImage}
|
||||
/>
|
||||
</Column>
|
||||
<Column>
|
||||
<Text style={endorsementTextStyle}>"{endorsementText}"</Text>
|
||||
</Column>
|
||||
</Row>
|
||||
|
||||
<Button href={endorsementUrl} style={button}>
|
||||
View endorsement
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
<Section style={footer}>
|
||||
<Text style={footerText}>
|
||||
This e-mail has been sent to {name},{' '}
|
||||
{/* <Link href={unsubscribeUrl} style={footerLink}>
|
||||
click here to unsubscribe from this type of notification
|
||||
</Link>
|
||||
. */}
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
|
||||
NewEndorsementEmail.PreviewProps = {
|
||||
fromUser: jamesUser,
|
||||
onUser: sinclairUser,
|
||||
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://manifold.love/unsubscribe',
|
||||
} as NewEndorsementEmailProps
|
||||
|
||||
const main = {
|
||||
backgroundColor: '#f4f4f4',
|
||||
fontFamily: 'Arial, sans-serif',
|
||||
wordSpacing: 'normal',
|
||||
}
|
||||
|
||||
const container = {
|
||||
margin: '0 auto',
|
||||
maxWidth: '600px',
|
||||
}
|
||||
|
||||
const logoContainer = {
|
||||
padding: '20px 0px 5px 0px',
|
||||
textAlign: 'center' as const,
|
||||
backgroundColor: '#ffffff',
|
||||
}
|
||||
|
||||
const content = {
|
||||
backgroundColor: '#ffffff',
|
||||
padding: '20px 25px',
|
||||
}
|
||||
|
||||
const paragraph = {
|
||||
fontSize: '18px',
|
||||
lineHeight: '24px',
|
||||
margin: '10px 0',
|
||||
color: '#000000',
|
||||
fontFamily: 'Arial, Helvetica, sans-serif',
|
||||
}
|
||||
|
||||
const endorsementContainer = {
|
||||
margin: '20px 0',
|
||||
padding: '15px',
|
||||
backgroundColor: '#f9f9f9',
|
||||
borderRadius: '8px',
|
||||
}
|
||||
|
||||
const avatarImage = {
|
||||
borderRadius: '50%',
|
||||
}
|
||||
|
||||
const endorsementTextStyle = {
|
||||
fontSize: '16px',
|
||||
lineHeight: '22px',
|
||||
fontStyle: 'italic',
|
||||
color: '#333333',
|
||||
}
|
||||
|
||||
const button = {
|
||||
backgroundColor: '#ec489a',
|
||||
borderRadius: '12px',
|
||||
color: '#ffffff',
|
||||
fontFamily: 'Helvetica, Arial, sans-serif',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'semibold',
|
||||
textDecoration: 'none',
|
||||
textAlign: 'center' as const,
|
||||
display: 'inline-block',
|
||||
padding: '6px 10px',
|
||||
margin: '10px 0',
|
||||
}
|
||||
|
||||
const footer = {
|
||||
margin: '20px 0',
|
||||
textAlign: 'center' as const,
|
||||
}
|
||||
|
||||
const footerText = {
|
||||
fontSize: '11px',
|
||||
lineHeight: '22px',
|
||||
color: '#000000',
|
||||
fontFamily: 'Ubuntu, Helvetica, Arial, sans-serif',
|
||||
}
|
||||
|
||||
const footerLink = {
|
||||
color: 'inherit',
|
||||
textDecoration: 'none',
|
||||
}
|
||||
|
||||
export default NewEndorsementEmail
|
||||
167
backend/email/emails/new-match.tsx
Normal file
167
backend/email/emails/new-match.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
} from '@react-email/components'
|
||||
import { DOMAIN } from 'common/envs/constants'
|
||||
import { type LoverRow } from 'common/love/lover'
|
||||
import { getLoveOgImageUrl } from 'common/love/og-image'
|
||||
import { type User } from 'common/user'
|
||||
import { jamesLover, jamesUser, sinclairUser } from './functions/mock'
|
||||
|
||||
interface NewMatchEmailProps {
|
||||
onUser: User
|
||||
matchedWithUser: User
|
||||
matchedLover: LoverRow
|
||||
unsubscribeUrl: string
|
||||
}
|
||||
|
||||
export const NewMatchEmail = ({
|
||||
onUser,
|
||||
matchedWithUser,
|
||||
matchedLover,
|
||||
unsubscribeUrl,
|
||||
}: NewMatchEmailProps) => {
|
||||
const name = onUser.name.split(' ')[0]
|
||||
const userImgSrc = getLoveOgImageUrl(matchedWithUser, matchedLover)
|
||||
const userUrl = `https://${DOMAIN}/${matchedWithUser.username}`
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>You have a new match!</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Section style={logoContainer}>
|
||||
<Img
|
||||
src="https://manifold.love/manifold-love-banner.png"
|
||||
width="550"
|
||||
height="auto"
|
||||
alt="manifold.love"
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>Hi {name},</Text>
|
||||
|
||||
<Text style={paragraph}>
|
||||
{matchedWithUser.name} just matched with you!
|
||||
</Text>
|
||||
|
||||
<Section style={imageContainer}>
|
||||
<Link href={userUrl}>
|
||||
<Img
|
||||
src={userImgSrc}
|
||||
width="375"
|
||||
height="200"
|
||||
alt=""
|
||||
style={profileImage}
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<Button href={userUrl} style={button}>
|
||||
View profile
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
<Section style={footer}>
|
||||
<Text style={footerText}>
|
||||
This e-mail has been sent to {name},{' '}
|
||||
{/* <Link href={unsubscribeUrl} style={footerLink}>
|
||||
click here to unsubscribe from this type of notification
|
||||
</Link>
|
||||
. */}
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
|
||||
NewMatchEmail.PreviewProps = {
|
||||
onUser: sinclairUser,
|
||||
matchedWithUser: jamesUser,
|
||||
matchedLover: jamesLover,
|
||||
unsubscribeUrl: 'https://manifold.love/unsubscribe',
|
||||
} as NewMatchEmailProps
|
||||
|
||||
const main = {
|
||||
backgroundColor: '#f4f4f4',
|
||||
fontFamily: 'Arial, sans-serif',
|
||||
wordSpacing: 'normal',
|
||||
}
|
||||
|
||||
const container = {
|
||||
margin: '0 auto',
|
||||
maxWidth: '600px',
|
||||
}
|
||||
|
||||
const logoContainer = {
|
||||
padding: '20px 0px 5px 0px',
|
||||
textAlign: 'center' as const,
|
||||
backgroundColor: '#ffffff',
|
||||
}
|
||||
|
||||
const content = {
|
||||
backgroundColor: '#ffffff',
|
||||
padding: '20px 25px',
|
||||
}
|
||||
|
||||
const paragraph = {
|
||||
fontSize: '18px',
|
||||
lineHeight: '24px',
|
||||
margin: '10px 0',
|
||||
color: '#000000',
|
||||
fontFamily: 'Arial, Helvetica, sans-serif',
|
||||
}
|
||||
|
||||
const imageContainer = {
|
||||
textAlign: 'center' as const,
|
||||
margin: '20px 0',
|
||||
}
|
||||
|
||||
const profileImage = {
|
||||
border: '1px solid #ec489a',
|
||||
}
|
||||
|
||||
const button = {
|
||||
backgroundColor: '#ec489a',
|
||||
borderRadius: '12px',
|
||||
color: '#ffffff',
|
||||
fontFamily: 'Helvetica, Arial, sans-serif',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'semibold',
|
||||
textDecoration: 'none',
|
||||
textAlign: 'center' as const,
|
||||
display: 'inline-block',
|
||||
padding: '6px 10px',
|
||||
margin: '10px 0',
|
||||
}
|
||||
|
||||
const footer = {
|
||||
margin: '20px 0',
|
||||
textAlign: 'center' as const,
|
||||
}
|
||||
|
||||
const footerText = {
|
||||
fontSize: '11px',
|
||||
lineHeight: '22px',
|
||||
color: '#000000',
|
||||
fontFamily: 'Ubuntu, Helvetica, Arial, sans-serif',
|
||||
}
|
||||
|
||||
const footerLink = {
|
||||
color: 'inherit',
|
||||
textDecoration: 'none',
|
||||
}
|
||||
|
||||
export default NewMatchEmail
|
||||
169
backend/email/emails/new-message.tsx
Normal file
169
backend/email/emails/new-message.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import {
|
||||
Body,
|
||||
Button,
|
||||
Container,
|
||||
Head,
|
||||
Html,
|
||||
Img,
|
||||
Link,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
} from '@react-email/components'
|
||||
import { type User } from 'common/user'
|
||||
import { type LoverRow } from 'common/love/lover'
|
||||
import {
|
||||
jamesLover,
|
||||
jamesUser,
|
||||
sinclairLover,
|
||||
sinclairUser,
|
||||
} from './functions/mock'
|
||||
import { DOMAIN } from 'common/envs/constants'
|
||||
import { getLoveOgImageUrl } from 'common/love/og-image'
|
||||
|
||||
interface NewMessageEmailProps {
|
||||
fromUser: User
|
||||
fromUserLover: LoverRow
|
||||
toUser: User
|
||||
channelId: number
|
||||
unsubscribeUrl: string
|
||||
}
|
||||
|
||||
export const NewMessageEmail = ({
|
||||
fromUser,
|
||||
fromUserLover,
|
||||
toUser,
|
||||
channelId,
|
||||
unsubscribeUrl,
|
||||
}: NewMessageEmailProps) => {
|
||||
const name = toUser.name.split(' ')[0]
|
||||
const creatorName = fromUser.name
|
||||
const messagesUrl = `https://${DOMAIN}/messages/${channelId}`
|
||||
const userImgSrc = getLoveOgImageUrl(fromUser, fromUserLover)
|
||||
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>New message from {creatorName}</Preview>
|
||||
<Body style={main}>
|
||||
<Container style={container}>
|
||||
<Section style={logoContainer}>
|
||||
<Img
|
||||
src="https://manifold.love/manifold-love-banner.png"
|
||||
width="550"
|
||||
height="auto"
|
||||
alt="manifold.love"
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section style={content}>
|
||||
<Text style={paragraph}>Hi {name},</Text>
|
||||
|
||||
<Text style={paragraph}>{creatorName} just messaged you!</Text>
|
||||
|
||||
<Section style={imageContainer}>
|
||||
<Link href={messagesUrl}>
|
||||
<Img
|
||||
src={userImgSrc}
|
||||
width="375"
|
||||
height="200"
|
||||
alt={`${creatorName}'s profile`}
|
||||
style={profileImage}
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<Button href={messagesUrl} style={button}>
|
||||
View message
|
||||
</Button>
|
||||
</Section>
|
||||
</Section>
|
||||
|
||||
<Section style={footer}>
|
||||
<Text style={footerText}>
|
||||
This e-mail has been sent to {name},{' '}
|
||||
{/* <Link href={unsubscribeUrl} style={{ color: 'inherit', textDecoration: 'none' }}>
|
||||
click here to unsubscribe from this type of notification
|
||||
</Link>
|
||||
. */}
|
||||
</Text>
|
||||
</Section>
|
||||
</Container>
|
||||
</Body>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
|
||||
NewMessageEmail.PreviewProps = {
|
||||
fromUser: jamesUser,
|
||||
fromUserLover: jamesLover,
|
||||
toUser: sinclairUser,
|
||||
channelId: 1,
|
||||
unsubscribeUrl: 'https://manifold.love/unsubscribe',
|
||||
} as NewMessageEmailProps
|
||||
|
||||
const main = {
|
||||
backgroundColor: '#f4f4f4',
|
||||
fontFamily: 'Arial, sans-serif',
|
||||
wordSpacing: 'normal',
|
||||
}
|
||||
|
||||
const container = {
|
||||
margin: '0 auto',
|
||||
maxWidth: '600px',
|
||||
}
|
||||
|
||||
const logoContainer = {
|
||||
padding: '20px 0px 5px 0px',
|
||||
textAlign: 'center' as const,
|
||||
backgroundColor: '#ffffff',
|
||||
}
|
||||
|
||||
const content = {
|
||||
backgroundColor: '#ffffff',
|
||||
padding: '20px 25px',
|
||||
}
|
||||
|
||||
const paragraph = {
|
||||
fontSize: '18px',
|
||||
lineHeight: '24px',
|
||||
margin: '10px 0',
|
||||
color: '#000000',
|
||||
fontFamily: 'Arial, Helvetica, sans-serif',
|
||||
}
|
||||
|
||||
const imageContainer = {
|
||||
textAlign: 'center' as const,
|
||||
margin: '20px 0',
|
||||
}
|
||||
|
||||
const profileImage = {
|
||||
border: '1px solid #ec489a',
|
||||
}
|
||||
|
||||
const button = {
|
||||
backgroundColor: '#ec489a',
|
||||
borderRadius: '12px',
|
||||
color: '#ffffff',
|
||||
fontFamily: 'Helvetica, Arial, sans-serif',
|
||||
fontSize: '16px',
|
||||
fontWeight: 'semibold',
|
||||
textDecoration: 'none',
|
||||
textAlign: 'center' as const,
|
||||
display: 'inline-block',
|
||||
padding: '6px 10px',
|
||||
margin: '10px 0',
|
||||
}
|
||||
|
||||
const footer = {
|
||||
margin: '20px 0',
|
||||
textAlign: 'center' as const,
|
||||
}
|
||||
|
||||
const footerText = {
|
||||
fontSize: '11px',
|
||||
lineHeight: '22px',
|
||||
color: '#000000',
|
||||
fontFamily: 'Ubuntu, Helvetica, Arial, sans-serif',
|
||||
}
|
||||
|
||||
export default NewMessageEmail
|
||||
BIN
backend/email/emails/static/manifold-love-banner.png
Normal file
BIN
backend/email/emails/static/manifold-love-banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
22
backend/email/emails/test.tsx
Normal file
22
backend/email/emails/test.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
'use server'
|
||||
|
||||
import { Head, Html, Preview, Tailwind, Text } from '@react-email/components'
|
||||
import React from 'react'
|
||||
|
||||
export const Test = (props: { name: string }) => {
|
||||
return (
|
||||
<Html>
|
||||
<Head />
|
||||
<Preview>Helloo {props.name}</Preview>
|
||||
<Tailwind>
|
||||
<Text className="text-xl text-blue-800">Hello {props.name}</Text>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
)
|
||||
}
|
||||
|
||||
Test.PreviewProps = {
|
||||
name: 'Clarity',
|
||||
}
|
||||
|
||||
export default Test
|
||||
38
backend/email/knowledge.md
Normal file
38
backend/email/knowledge.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Email Knowledge
|
||||
|
||||
## Overview
|
||||
|
||||
The email module provides React Email components for sending beautiful, responsive emails from the application. We use the React Email for templates and Resend for delivery.
|
||||
|
||||
## Structure
|
||||
|
||||
- `emails/` - Contains all email templates and helper functions
|
||||
- `functions/` - Helper functions for sending emails
|
||||
- `helpers.tsx` - Core email sending functions
|
||||
- `send-email.ts` - Low-level email sending utilities
|
||||
- `static/` - This folder is useless. Includes image assets for the dev preview server.
|
||||
|
||||
## Usage
|
||||
|
||||
### Sending Emails
|
||||
|
||||
Import the helper functions from the email module to send emails:
|
||||
|
||||
```typescript
|
||||
import { sendNewEndorsementEmail } from 'email/functions/helpers'
|
||||
|
||||
// Example usage
|
||||
await sendNewEndorsementEmail(privateUser, creator, onUser, text)
|
||||
```
|
||||
|
||||
### Creating New Email Templates
|
||||
|
||||
1. Create a new React component in the `emails/` directory
|
||||
2. Use components from `@react-email/components` for email-safe HTML
|
||||
3. Add preview props
|
||||
4. Export the component as default
|
||||
5. Add a helper function in `functions/helpers.tsx` to send the email
|
||||
|
||||
### Development
|
||||
|
||||
You may run typechecks but you don't need to start the email dev server. Assume the human developer is responsible for that.
|
||||
22
backend/email/package.json
Normal file
22
backend/email/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "react-email-starter",
|
||||
"version": "0.1.9",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "email dev",
|
||||
"build": "tsc -b"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-email/components": "0.0.33",
|
||||
"react": "19.0.0",
|
||||
"react-dom": "19.0.0",
|
||||
"react-email": "3.0.7",
|
||||
"resend": "4.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/html-to-text": "9.0.4",
|
||||
"@types/prismjs": "1.26.5",
|
||||
"@types/react": "19.0.10",
|
||||
"@types/react-dom": "19.0.4"
|
||||
}
|
||||
}
|
||||
27
backend/email/readme.md
Normal file
27
backend/email/readme.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# React Email Starter
|
||||
|
||||
A live preview right in your browser so you don't need to keep sending real emails during development.
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, install the dependencies:
|
||||
|
||||
```sh
|
||||
npm install
|
||||
# or
|
||||
yarn
|
||||
```
|
||||
|
||||
Then, run the development server:
|
||||
|
||||
```sh
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
```
|
||||
|
||||
Open [localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
34
backend/email/tsconfig.json
Normal file
34
backend/email/tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"rootDir": "emails",
|
||||
"composite": true,
|
||||
"module": "commonjs",
|
||||
"moduleResolution": "node",
|
||||
"noImplicitReturns": true,
|
||||
"outDir": "lib",
|
||||
"tsBuildInfoFile": "lib/tsconfig.tsbuildinfo",
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"target": "esnext",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"incremental": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": false,
|
||||
"declaration": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"common/*": ["../../common/src/*", "../../../common/lib/*"],
|
||||
"shared/*": ["../shared/src/*", "../../shared/lib/*"],
|
||||
"email/*": ["./emails/*"]
|
||||
}
|
||||
},
|
||||
"ts-node": {
|
||||
"require": ["tsconfig-paths/register"]
|
||||
},
|
||||
"references": [{ "path": "../../common" }, { "path": "../shared" }],
|
||||
"include": ["emails/**/*.ts", "emails/**/*.tsx"]
|
||||
}
|
||||
1776
backend/email/yarn.lock
Normal file
1776
backend/email/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
7
backend/firebase/.firebaserc
Normal file
7
backend/firebase/.firebaserc
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"projects": {
|
||||
"default": "polylove",
|
||||
"prod": "polylove",
|
||||
"dev": "polylove-dev"
|
||||
}
|
||||
}
|
||||
12
backend/firebase/firebase.json
Normal file
12
backend/firebase/firebase.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"storage": [
|
||||
{
|
||||
"bucket": "polylove.firebasestorage.app",
|
||||
"rules": "storage.rules"
|
||||
},
|
||||
{
|
||||
"bucket": "polylove-private.firebasestorage.app",
|
||||
"rules": "private-storage.rules"
|
||||
}
|
||||
]
|
||||
}
|
||||
10
backend/firebase/private-storage.rules
Normal file
10
backend/firebase/private-storage.rules
Normal file
@@ -0,0 +1,10 @@
|
||||
rules_version = '2';
|
||||
|
||||
service firebase.storage {
|
||||
match /b/{bucket}/o {
|
||||
match /private-images/{userId}/{allPaths=**} {
|
||||
allow read: if request.auth.uid == userId;
|
||||
allow write: if request.auth.uid == userId && request.resource.size <= 20 * 1024 * 1024; // 20MB
|
||||
}
|
||||
}
|
||||
}
|
||||
11
backend/firebase/storage.rules
Normal file
11
backend/firebase/storage.rules
Normal file
@@ -0,0 +1,11 @@
|
||||
rules_version = '2';
|
||||
|
||||
service firebase.storage {
|
||||
match /b/{bucket}/o {
|
||||
match /{allPaths=**} {
|
||||
allow read;
|
||||
// Don't require auth, as dream uploads can be done by anyone
|
||||
allow write: if request.resource.size <= 10 * 1024 * 1024; // 10MB
|
||||
}
|
||||
}
|
||||
}
|
||||
18
backend/scripts/2024-03-10-migrate_dm_cols.sql
Normal file
18
backend/scripts/2024-03-10-migrate_dm_cols.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
update private_user_messages
|
||||
set created_time = now() where created_time is null;
|
||||
|
||||
alter table private_user_messages
|
||||
alter column created_time set not null,
|
||||
alter column created_time set default now(),
|
||||
alter column visibility set not null,
|
||||
alter column visibility set default 'private',
|
||||
alter column user_id set not null,
|
||||
alter column content set not null,
|
||||
alter column channel_id set not null;
|
||||
|
||||
alter table private_user_messages
|
||||
rename column id to old_id;
|
||||
|
||||
alter table private_user_messages
|
||||
add column id bigint generated always as identity primary key;
|
||||
|
||||
59
backend/scripts/2025-04-23-migrate-social-links.ts
Normal file
59
backend/scripts/2025-04-23-migrate-social-links.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { removeUndefinedProps } from 'common/util/object'
|
||||
import { runScript } from './run-script'
|
||||
import { log } from 'shared/monitoring/log'
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { bulkUpdateData } from 'shared/supabase/utils'
|
||||
import { chunk } from 'lodash'
|
||||
|
||||
runScript(async ({ pg }) => {
|
||||
const directClient = createSupabaseDirectClient()
|
||||
|
||||
// Get all users and their corresponding lovers
|
||||
const users = await directClient.manyOrNone(`
|
||||
select u.id, u.data, l.twitter
|
||||
from users u
|
||||
left join lovers l on l.user_id = u.id
|
||||
`)
|
||||
|
||||
log('Found', users.length, 'users to migrate')
|
||||
|
||||
const updates = [] as { id: string; link: {} }[]
|
||||
|
||||
for (const { id, data, twitter } of users) {
|
||||
const add = removeUndefinedProps({
|
||||
discord: data.discordHandle,
|
||||
manifold: data.manifoldHandle,
|
||||
x: (twitter || data.twitterHandle)
|
||||
?.trim()
|
||||
.replace(/^(https?:\/\/)?(www\.)?(twitter|x)(\.com\/)/, '')
|
||||
.replace(/^@/, '')
|
||||
.replace(/\/$/, ''),
|
||||
site: data.website?.trim().replace(/^(https?:\/\/)/, ''),
|
||||
})
|
||||
|
||||
if (Object.keys(add).length) {
|
||||
updates.push({ id, link: { ...add, ...(data.link || {}) } })
|
||||
}
|
||||
}
|
||||
|
||||
// console.log('updates', updates.slice(0, 10))
|
||||
// return
|
||||
|
||||
let count = 0
|
||||
for (const u of chunk(updates, 100)) {
|
||||
log('updating users ', (count += u.length))
|
||||
await bulkUpdateData(pg, 'users', u)
|
||||
}
|
||||
|
||||
log('initializing the other users')
|
||||
await pg.none(
|
||||
`update users
|
||||
set data = jsonb_set(
|
||||
data,
|
||||
'{link}',
|
||||
COALESCE((data -> 'link'), '{}'::jsonb),
|
||||
true
|
||||
)
|
||||
where data -> 'link' is null`
|
||||
)
|
||||
})
|
||||
11
backend/scripts/2025-04-26-init-empty-social-links.sql
Normal file
11
backend/scripts/2025-04-26-init-empty-social-links.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- historical hotfix. you shouldn't need to run this
|
||||
update users
|
||||
set
|
||||
data = jsonb_set(
|
||||
data,
|
||||
'{link}',
|
||||
coalesce((data -> 'link'), '{}'::jsonb),
|
||||
true
|
||||
)
|
||||
where
|
||||
data -> 'link' is null
|
||||
72
backend/scripts/find-tiptap-nodes.ts
Normal file
72
backend/scripts/find-tiptap-nodes.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { runScript } from './run-script'
|
||||
import {
|
||||
renderSql,
|
||||
select,
|
||||
from,
|
||||
where,
|
||||
} from '../shared/src/supabase/sql-builder'
|
||||
import { SupabaseDirectClient } from 'shared/supabase/init'
|
||||
|
||||
runScript(async ({ pg }) => {
|
||||
const tests = [
|
||||
'mention',
|
||||
'contract-mention',
|
||||
'tiptapTweet',
|
||||
'spoiler',
|
||||
'iframe',
|
||||
'linkPreview',
|
||||
'gridCardsComponent',
|
||||
]
|
||||
|
||||
for (const test of tests) {
|
||||
await getNodes(pg, test)
|
||||
}
|
||||
})
|
||||
|
||||
const getNodes = async (pg: SupabaseDirectClient, nodeName: string) => {
|
||||
console.log(`\nSearching comments for ${nodeName}...`)
|
||||
const commentQuery = renderSql(
|
||||
select('id, user_id, on_user_id, content'),
|
||||
from('lover_comments'),
|
||||
where(`jsonb_path_exists(content, '$.**.type ? (@ == "${nodeName}")')`)
|
||||
)
|
||||
const comments = await pg.manyOrNone(commentQuery)
|
||||
|
||||
console.log(`Found ${comments.length} comments:`)
|
||||
comments.forEach((comment) => {
|
||||
console.log('\nComment ID:', comment.id)
|
||||
console.log('From user:', comment.user_id)
|
||||
console.log('On user:', comment.on_user_id)
|
||||
console.log('Content:', JSON.stringify(comment.content))
|
||||
})
|
||||
|
||||
console.log(`\nSearching private messages for ${nodeName}...`)
|
||||
const messageQuery = renderSql(
|
||||
select('id, user_id, channel_id, content'),
|
||||
from('private_user_messages'),
|
||||
where(`jsonb_path_exists(content, '$.**.type ? (@ == "${nodeName}")')`)
|
||||
)
|
||||
const messages = await pg.manyOrNone(messageQuery)
|
||||
|
||||
console.log(`Found ${messages.length} private messages:`)
|
||||
messages.forEach((msg) => {
|
||||
console.log('\nMessage ID:', msg.id)
|
||||
console.log('From user:', msg.user_id)
|
||||
console.log('Channel:', msg.channel_id)
|
||||
console.log('Content:', JSON.stringify(msg.content))
|
||||
})
|
||||
|
||||
console.log(`\nSearching profiles for ${nodeName}...`)
|
||||
const users = renderSql(
|
||||
select('user_id, bio'),
|
||||
from('lovers'),
|
||||
where(`jsonb_path_exists(bio::jsonb, '$.**.type ? (@ == "${nodeName}")')`)
|
||||
)
|
||||
|
||||
const usersWithMentions = await pg.manyOrNone(users)
|
||||
console.log(`Found ${usersWithMentions.length} users:`)
|
||||
usersWithMentions.forEach((user) => {
|
||||
console.log('\nUser ID:', user.user_id)
|
||||
console.log('Bio:', JSON.stringify(user.bio))
|
||||
})
|
||||
}
|
||||
35
backend/scripts/import-love-finalize.sql
Normal file
35
backend/scripts/import-love-finalize.sql
Normal file
@@ -0,0 +1,35 @@
|
||||
|
||||
-- Copy data into tables
|
||||
insert into
|
||||
users (data, id, name, username, created_time)
|
||||
select
|
||||
user_data, id, name, username, created_time
|
||||
from
|
||||
temp_users;
|
||||
|
||||
insert into
|
||||
private_users (data, id)
|
||||
select
|
||||
private_user_data, id
|
||||
from
|
||||
temp_users;
|
||||
|
||||
-- Rename temp_love_messages
|
||||
-- alter table temp_love_messages
|
||||
-- rename to private_user_messages;
|
||||
|
||||
-- alter table private_user_messages
|
||||
-- alter column channel_id set not null,
|
||||
-- alter column content set not null,
|
||||
-- alter column created_time set not null,
|
||||
-- alter column created_time set default now(),
|
||||
-- alter column id set not null,
|
||||
-- alter column user_id set not null,
|
||||
-- alter column visibility set not null,
|
||||
-- alter column visibility set default 'private';
|
||||
|
||||
-- alter table private_user_messages
|
||||
-- alter column id add generated always as identity;
|
||||
|
||||
-- alter table private_user_messages
|
||||
-- add constraint private_user_messages_pkey primary key (id);
|
||||
32
backend/scripts/import-love-tables.sh
Executable file
32
backend/scripts/import-love-tables.sh
Executable file
@@ -0,0 +1,32 @@
|
||||
#!/bin/bash
|
||||
|
||||
(
|
||||
# Set your database connection details
|
||||
export PGPASSWORD=$SUPABASE_PASSWORD
|
||||
|
||||
# Target database connection info - replace with your target DB
|
||||
|
||||
# DB_NAME="db.gxbejryrwhsmuailcdur.supabase.co" # dev
|
||||
DB_NAME="db.lltoaluoavlzrgjplire.supabase.co" # prod
|
||||
DB_USER="postgres"
|
||||
PORT="5432"
|
||||
|
||||
psql -U $DB_USER -d postgres -h $DB_NAME -p $PORT -w \
|
||||
-f ./love-stars-dump.sql \
|
||||
|
||||
|
||||
# psql -U $DB_USER -d postgres -h $DB_NAME -p $PORT -w \
|
||||
# -c 'drop table temp_users cascade;'
|
||||
|
||||
# psql -U $DB_USER -d postgres -h $DB_NAME -p $PORT -w \
|
||||
# -f ./temp-users-dump.sql \
|
||||
|
||||
# psql -U $DB_USER -d postgres -h $DB_NAME -p $PORT -w \
|
||||
# -f ../supabase/private_users.sql \
|
||||
# -f ../supabase/users.sql
|
||||
|
||||
# psql -U $DB_USER -d postgres -h $DB_NAME -p $PORT -w \
|
||||
# -f './import-love-finalize.sql'
|
||||
|
||||
echo "Done"
|
||||
)
|
||||
357
backend/scripts/regen-schema.ts
Normal file
357
backend/scripts/regen-schema.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
import * as fs from 'fs/promises'
|
||||
import { execSync } from 'child_process'
|
||||
import { type SupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { runScript } from 'run-script'
|
||||
|
||||
const outputDir = `../supabase/`
|
||||
|
||||
runScript(async ({ pg }) => {
|
||||
// make the output directory if it doesn't exist
|
||||
execSync(`mkdir -p ${outputDir}`)
|
||||
// delete all sql files except seed.sql
|
||||
execSync(
|
||||
`cd ${outputDir} && find *.sql -type f ! -name seed.sql -delete || true`
|
||||
)
|
||||
await generateSQLFiles(pg)
|
||||
})
|
||||
|
||||
async function getTableInfo(pg: SupabaseDirectClient, tableName: string) {
|
||||
const columns = await pg.manyOrNone<{
|
||||
name: string
|
||||
type: string
|
||||
not_null: boolean
|
||||
default: string | null
|
||||
identity: boolean
|
||||
always: 'BY DEFAULT' | 'ALWAYS'
|
||||
gen: string | null
|
||||
stored: 'STORED' | 'VIRTUAL'
|
||||
}>(
|
||||
`SELECT
|
||||
column_name as name,
|
||||
format_type(a.atttypid, a.atttypmod) as type,
|
||||
is_nullable = 'NO' as not_null,
|
||||
column_default as default,
|
||||
is_identity = 'YES' as identity,
|
||||
identity_generation as always,
|
||||
pg_get_expr(d.adbin, d.adrelid, true) AS gen,
|
||||
CASE
|
||||
WHEN a.attgenerated = 's' THEN 'STORED'
|
||||
WHEN a.attgenerated = 'v' THEN 'VIRTUAL'
|
||||
ELSE NULL
|
||||
END AS stored
|
||||
FROM information_schema.columns c
|
||||
LEFT JOIN pg_catalog.pg_attribute a
|
||||
ON a.attrelid = c.table_name::regclass
|
||||
AND a.attname = c.column_name
|
||||
AND NOT a.attisdropped
|
||||
JOIN pg_catalog.pg_type t ON t.oid = a.atttypid
|
||||
LEFT JOIN pg_catalog.pg_attrdef d
|
||||
ON d.adrelid = a.attrelid
|
||||
AND d.adnum = a.attnum
|
||||
WHERE table_schema = 'public' AND table_name = $1
|
||||
ORDER BY column_name`,
|
||||
[tableName]
|
||||
)
|
||||
|
||||
const checks = await pg.manyOrNone<{
|
||||
name: string
|
||||
definition: string
|
||||
}>(
|
||||
`SELECT
|
||||
cc.constraint_name as name,
|
||||
cc.check_clause as definition
|
||||
FROM information_schema.table_constraints tc
|
||||
join information_schema.check_constraints cc
|
||||
ON tc.constraint_schema = cc.constraint_schema
|
||||
AND tc.constraint_name = cc.constraint_name
|
||||
WHERE tc.constraint_type = 'CHECK'
|
||||
AND NOT cc.check_clause ilike '% IS NOT NULL'
|
||||
AND tc.table_schema = 'public'
|
||||
AND tc.table_name = $1`,
|
||||
[tableName]
|
||||
)
|
||||
|
||||
const primaryKeys = await pg.map(
|
||||
`SELECT c.column_name
|
||||
FROM
|
||||
information_schema.table_constraints tc
|
||||
JOIN
|
||||
information_schema.constraint_column_usage AS ccu
|
||||
USING (constraint_schema, constraint_name)
|
||||
JOIN information_schema.columns AS c
|
||||
ON c.table_schema = tc.constraint_schema
|
||||
AND tc.table_name = c.table_name
|
||||
AND ccu.column_name = c.column_name
|
||||
WHERE constraint_type = 'PRIMARY KEY' AND tc.table_schema = 'public' AND tc.table_name = $1`,
|
||||
[tableName],
|
||||
(row) => row.column_name as string
|
||||
)
|
||||
|
||||
const foreignKeys = await pg.manyOrNone<{
|
||||
constraint_name: string
|
||||
definition: string
|
||||
}>(
|
||||
`SELECT
|
||||
conname AS constraint_name,
|
||||
pg_get_constraintdef(c.oid) AS definition
|
||||
FROM
|
||||
pg_constraint c
|
||||
JOIN
|
||||
pg_namespace n ON n.oid = c.connamespace
|
||||
WHERE
|
||||
contype = 'f'
|
||||
AND conrelid = $1::regclass`,
|
||||
[tableName]
|
||||
)
|
||||
|
||||
const triggers = await pg.manyOrNone<{
|
||||
trigger_name: string
|
||||
definition: string
|
||||
}>(
|
||||
`SELECT
|
||||
tgname AS trigger_name,
|
||||
pg_get_triggerdef(t.oid) AS definition
|
||||
FROM
|
||||
pg_trigger t
|
||||
WHERE
|
||||
tgrelid = $1::regclass
|
||||
AND NOT tgisinternal`,
|
||||
[tableName]
|
||||
)
|
||||
const rlsEnabled = await pg.one(
|
||||
`SELECT relrowsecurity
|
||||
FROM pg_class
|
||||
WHERE oid = $1::regclass`,
|
||||
[tableName]
|
||||
)
|
||||
const rls = !!rlsEnabled.relrowsecurity
|
||||
|
||||
const policies = await pg.any(
|
||||
`SELECT
|
||||
polname AS policy_name,
|
||||
pg_get_expr(polqual, polrelid) AS expression,
|
||||
pg_get_expr(polwithcheck, polrelid) AS with_check,
|
||||
(select r.rolname from unnest(polroles) u join pg_roles r on r.oid = u.u) AS role,
|
||||
CASE
|
||||
WHEN polcmd = '*' THEN 'ALL'
|
||||
WHEN polcmd = 'r' THEN 'SELECT'
|
||||
WHEN polcmd = 'a' THEN 'INSERT'
|
||||
WHEN polcmd = 'w' THEN 'UPDATE'
|
||||
WHEN polcmd = 'd' THEN 'DELETE'
|
||||
ELSE polcmd::text
|
||||
END AS command
|
||||
FROM
|
||||
pg_policy
|
||||
WHERE
|
||||
polrelid = $1::regclass`,
|
||||
[tableName]
|
||||
)
|
||||
|
||||
const indexes = await pg.manyOrNone<{
|
||||
index_name: string
|
||||
definition: string
|
||||
}>(
|
||||
`SELECT
|
||||
indexname AS index_name,
|
||||
indexdef AS definition
|
||||
FROM
|
||||
pg_indexes
|
||||
WHERE
|
||||
schemaname = 'public'
|
||||
AND tablename = $1
|
||||
ORDER BY
|
||||
indexname`,
|
||||
[tableName]
|
||||
)
|
||||
|
||||
return {
|
||||
tableName,
|
||||
columns,
|
||||
checks,
|
||||
primaryKeys,
|
||||
foreignKeys,
|
||||
triggers,
|
||||
rls,
|
||||
policies,
|
||||
indexes,
|
||||
}
|
||||
}
|
||||
|
||||
async function getFunctions(pg: SupabaseDirectClient) {
|
||||
console.log('Getting functions')
|
||||
const rows = await pg.manyOrNone<{
|
||||
function_name: string
|
||||
definition: string
|
||||
}>(
|
||||
`SELECT
|
||||
proname AS function_name,
|
||||
pg_get_functiondef(oid) AS definition
|
||||
FROM pg_proc
|
||||
WHERE
|
||||
pronamespace = 'public'::regnamespace
|
||||
and prokind = 'f'
|
||||
ORDER BY proname asc, pronargs asc, oid desc`
|
||||
)
|
||||
return rows.filter((f) => !f.definition.includes(`'$libdir/`))
|
||||
}
|
||||
|
||||
async function getViews(pg: SupabaseDirectClient) {
|
||||
console.log('Getting views')
|
||||
return pg.manyOrNone<{ view_name: string; definition: string }>(
|
||||
`SELECT
|
||||
table_name AS view_name,
|
||||
view_definition AS definition
|
||||
FROM information_schema.views
|
||||
where table_schema = 'public'
|
||||
ORDER BY table_name asc`
|
||||
)
|
||||
}
|
||||
|
||||
async function generateSQLFiles(pg: SupabaseDirectClient) {
|
||||
const tables = await pg.map(
|
||||
"SELECT tablename FROM pg_tables WHERE schemaname = 'public'",
|
||||
[],
|
||||
(row) => row.tablename as string
|
||||
)
|
||||
|
||||
console.log(`Getting info for ${tables.length} tables`)
|
||||
const tableInfos = await Promise.all(
|
||||
tables.map((table) => getTableInfo(pg, table))
|
||||
)
|
||||
const functions = await getFunctions(pg)
|
||||
const views = await getViews(pg)
|
||||
|
||||
for (const tableInfo of tableInfos) {
|
||||
let content = `-- This file is autogenerated from regen-schema.ts\n\n`
|
||||
|
||||
content += `CREATE TABLE IF NOT EXISTS ${tableInfo.tableName} (\n`
|
||||
|
||||
// organize check constraints by column
|
||||
const checksByColumn: {
|
||||
[col: string]: { name: string; definition: string }
|
||||
} = {}
|
||||
const remainingChecks = []
|
||||
for (const check of tableInfo.checks) {
|
||||
const matches = tableInfo.columns.filter((c) =>
|
||||
check.definition.includes(c.name)
|
||||
)
|
||||
|
||||
if (matches.length === 1) {
|
||||
checksByColumn[matches[0].name] = check
|
||||
} else {
|
||||
remainingChecks.push(check)
|
||||
}
|
||||
}
|
||||
|
||||
const pkeys = tableInfo.primaryKeys
|
||||
|
||||
for (const c of tableInfo.columns) {
|
||||
const isSerial = c.default?.startsWith('nextval(')
|
||||
|
||||
if (isSerial) {
|
||||
content += ` ${c.name} ${c.type === 'bigint' ? 'bigserial' : 'serial'}`
|
||||
} else {
|
||||
content += ` ${c.name} ${c.type}`
|
||||
if (pkeys.length === 1 && pkeys[0] === c.name)
|
||||
content += ` PRIMARY KEY ${tableInfo.tableName}_pkey`
|
||||
if (c.default) content += ` DEFAULT ${c.default}`
|
||||
else if (c.identity) content += ` GENERATED ${c.always} AS IDENTITY`
|
||||
else if (c.gen) content += ` GENERATED ALWAYS AS (${c.gen}) ${c.stored}`
|
||||
}
|
||||
if (c.not_null) content += ' NOT NULL'
|
||||
const check = checksByColumn[c.name]
|
||||
if (check)
|
||||
content += ` CONSTRAINT ${check.name} CHECK ${check.definition}`
|
||||
|
||||
content += ',\n'
|
||||
}
|
||||
|
||||
if (pkeys.length > 1) {
|
||||
content += ` CONSTRAINT PRIMARY KEY (${pkeys.join(', ')}),\n`
|
||||
}
|
||||
|
||||
for (const check of remainingChecks) {
|
||||
content += ` CONSTRAINT ${check.name} CHECK ${check.definition},\n`
|
||||
}
|
||||
|
||||
// remove the trailing comma
|
||||
content = content.replace(/,(?=[^,]+$)/, '')
|
||||
content += ');\n\n'
|
||||
|
||||
if (tableInfo.foreignKeys.length > 0) content += `-- Foreign Keys\n`
|
||||
for (const fk of tableInfo.foreignKeys) {
|
||||
content += `ALTER TABLE ${tableInfo.tableName} ADD CONSTRAINT ${fk.constraint_name} ${fk.definition};\n`
|
||||
}
|
||||
content += '\n'
|
||||
|
||||
const tableFunctions = []
|
||||
|
||||
if (tableInfo.triggers.length > 0) content += `-- Triggers\n`
|
||||
for (const trigger of tableInfo.triggers) {
|
||||
content += `${trigger.definition};\n`
|
||||
|
||||
const funcName = trigger.definition.match(/execute function (\w+)/i)?.[1]
|
||||
if (funcName) tableFunctions.push(funcName)
|
||||
}
|
||||
content += '\n'
|
||||
|
||||
if (tableFunctions.length > 0) content += `-- Functions\n`
|
||||
for (const func of tableFunctions) {
|
||||
const i = functions.findIndex((f) => f.function_name === func)
|
||||
if (i >= 0) {
|
||||
content += `${functions[i].definition};\n\n`
|
||||
functions.splice(i, 1) // remove from list so we don't duplicate
|
||||
}
|
||||
}
|
||||
if (tableInfo.rls) {
|
||||
content += `-- Row Level Security\n`
|
||||
content += `ALTER TABLE ${tableInfo.tableName} ENABLE ROW LEVEL SECURITY;\n`
|
||||
}
|
||||
|
||||
if (tableInfo.policies.length > 0) {
|
||||
content += `-- Policies\n`
|
||||
}
|
||||
for (const policy of tableInfo.policies) {
|
||||
content += `DROP POLICY IF EXISTS "${policy.policy_name}" ON ${tableInfo.tableName};\n`
|
||||
content += `CREATE POLICY "${policy.policy_name}" ON ${tableInfo.tableName} `
|
||||
if (policy.command) content += `FOR ${policy.command} `
|
||||
if (policy.role) content += `TO ${policy.role} `
|
||||
if (policy.expression) content += `USING (${policy.expression}) `
|
||||
if (policy.with_check) content += `WITH CHECK (${policy.with_check})`
|
||||
content += ';\n\n'
|
||||
}
|
||||
|
||||
if (tableInfo.indexes.length > 0) content += `-- Indexes\n`
|
||||
for (const index of tableInfo.indexes) {
|
||||
content += `DROP INDEX IF EXISTS ${index.index_name};\n`
|
||||
content += `${index.definition};\n`
|
||||
}
|
||||
content += '\n'
|
||||
|
||||
await fs.writeFile(`${outputDir}/${tableInfo.tableName}.sql`, content)
|
||||
}
|
||||
|
||||
console.log('Writing remaining functions to functions.sql')
|
||||
let functionsContent = `-- This file is autogenerated from regen-schema.ts\n\n`
|
||||
|
||||
for (const func of functions) {
|
||||
functionsContent += `${func.definition};\n\n`
|
||||
}
|
||||
|
||||
await fs.writeFile(`${outputDir}/functions.sql`, functionsContent)
|
||||
|
||||
console.log('Writing views to views.sql')
|
||||
let viewsContent = `-- This file is autogenerated from regen-schema.ts\n\n`
|
||||
|
||||
for (const view of views) {
|
||||
viewsContent += `CREATE OR REPLACE VIEW ${view.view_name} AS\n`
|
||||
viewsContent += `${view.definition}\n\n`
|
||||
}
|
||||
|
||||
await fs.writeFile(`${outputDir}/views.sql`, viewsContent)
|
||||
|
||||
console.log('Prettifying SQL files...')
|
||||
execSync(
|
||||
`prettier --write ${outputDir}/*.sql --ignore-path ../supabase/.gitignore`
|
||||
)
|
||||
}
|
||||
76
backend/scripts/remove-tiptap-nodes.ts
Normal file
76
backend/scripts/remove-tiptap-nodes.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { runScript } from './run-script'
|
||||
import {
|
||||
renderSql,
|
||||
select,
|
||||
from,
|
||||
where,
|
||||
} from '../shared/src/supabase/sql-builder'
|
||||
import { type JSONContent } from '@tiptap/core'
|
||||
|
||||
const removeNodesOfType = (
|
||||
content: JSONContent,
|
||||
typeToRemove: string
|
||||
): JSONContent | null => {
|
||||
if (content.type === typeToRemove) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (content.content) {
|
||||
const newContent = content.content
|
||||
.map((node) => removeNodesOfType(node, typeToRemove))
|
||||
.filter((node) => node != null)
|
||||
|
||||
return { ...content, content: newContent }
|
||||
}
|
||||
|
||||
// No content to process, return node as is
|
||||
return content
|
||||
}
|
||||
|
||||
runScript(async ({ pg }) => {
|
||||
const nodeType = 'linkPreview'
|
||||
|
||||
console.log('\nSearching comments for linkPreviews...')
|
||||
const commentQuery = renderSql(
|
||||
select('id, content'),
|
||||
from('lover_comments'),
|
||||
where(`jsonb_path_exists(content, '$.**.type ? (@ == "${nodeType}")')`)
|
||||
)
|
||||
const comments = await pg.manyOrNone(commentQuery)
|
||||
|
||||
console.log(`Found ${comments.length} comments with linkPreviews`)
|
||||
|
||||
for (const comment of comments) {
|
||||
const newContent = removeNodesOfType(comment.content, nodeType)
|
||||
console.log('before', comment.content)
|
||||
console.log('after', newContent)
|
||||
|
||||
await pg.none('update lover_comments set content = $1 where id = $2', [
|
||||
newContent,
|
||||
comment.id,
|
||||
])
|
||||
console.log('Updated comment:', comment.id)
|
||||
}
|
||||
|
||||
console.log('\nSearching private messages for linkPreviews...')
|
||||
const messageQuery = renderSql(
|
||||
select('id, content'),
|
||||
from('private_user_messages'),
|
||||
where(`jsonb_path_exists(content, '$.**.type ? (@ == "${nodeType}")')`)
|
||||
)
|
||||
const messages = await pg.manyOrNone(messageQuery)
|
||||
|
||||
console.log(`Found ${messages.length} messages with linkPreviews`)
|
||||
|
||||
for (const msg of messages) {
|
||||
const newContent = removeNodesOfType(msg.content, nodeType)
|
||||
console.log('before', JSON.stringify(msg.content, null, 2))
|
||||
console.log('after', JSON.stringify(newContent, null, 2))
|
||||
|
||||
await pg.none(
|
||||
'update private_user_messages set content = $1 where id = $2',
|
||||
[newContent, msg.id]
|
||||
)
|
||||
console.log('Updated message:', msg.id)
|
||||
}
|
||||
})
|
||||
22
backend/scripts/run-script.ts
Normal file
22
backend/scripts/run-script.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { getLocalEnv, initAdmin } from 'shared/init-admin'
|
||||
import { getServiceAccountCredentials, loadSecretsToEnv } from 'common/secrets'
|
||||
import {
|
||||
createSupabaseDirectClient,
|
||||
type SupabaseDirectClient,
|
||||
} from 'shared/supabase/init'
|
||||
|
||||
initAdmin()
|
||||
|
||||
export const runScript = async (
|
||||
main: (services: { pg: SupabaseDirectClient }) => Promise<any> | any
|
||||
) => {
|
||||
const env = getLocalEnv()
|
||||
const credentials = getServiceAccountCredentials(env)
|
||||
|
||||
await loadSecretsToEnv(credentials)
|
||||
|
||||
const pg = createSupabaseDirectClient()
|
||||
await main({ pg })
|
||||
|
||||
process.exit()
|
||||
}
|
||||
29
backend/scripts/tsconfig.json
Normal file
29
backend/scripts/tsconfig.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"composite": true,
|
||||
"module": "commonjs",
|
||||
"noImplicitReturns": true,
|
||||
"outDir": "./lib",
|
||||
"strict": true,
|
||||
"target": "esnext",
|
||||
"esModuleInterop": true,
|
||||
"paths": {
|
||||
"common/*": ["../../common/src/*", "../../../common/lib/*"],
|
||||
"api/*": ["../api/src/*", "../../api/lib/*"],
|
||||
"shared/*": ["../shared/src/*", "../../shared/lib/*"],
|
||||
"email/*": ["../email/emails/*", "../../email/lib/*"],
|
||||
"scripts/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"ts-node": {
|
||||
"require": ["tsconfig-paths/register"]
|
||||
},
|
||||
"references": [
|
||||
{ "path": "../../common" },
|
||||
{ "path": "../shared" },
|
||||
{ "path": "../api" },
|
||||
{ "path": "../email" }
|
||||
],
|
||||
"compileOnSave": true
|
||||
}
|
||||
50
backend/shared/.eslintrc.js
Normal file
50
backend/shared/.eslintrc.js
Normal file
@@ -0,0 +1,50 @@
|
||||
module.exports = {
|
||||
plugins: ['lodash', 'unused-imports'],
|
||||
extends: ['eslint:recommended'],
|
||||
ignorePatterns: ['dist', 'lib'],
|
||||
env: {
|
||||
node: true,
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
plugins: ['@typescript-eslint'],
|
||||
extends: ['plugin:@typescript-eslint/recommended'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
project: ['./tsconfig.json'],
|
||||
},
|
||||
rules: {
|
||||
'@typescript-eslint/ban-types': [
|
||||
'error',
|
||||
{
|
||||
extendDefaults: true,
|
||||
types: {
|
||||
'{}': false,
|
||||
},
|
||||
},
|
||||
],
|
||||
'@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'],
|
||||
},
|
||||
}
|
||||
2
backend/shared/.gitignore
vendored
Normal file
2
backend/shared/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Compiled JavaScript files
|
||||
lib/
|
||||
26
backend/shared/package.json
Normal file
26
backend/shared/package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "shared",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc -b && yarn --cwd=../../common tsc-alias && tsc-alias",
|
||||
"compile": "tsc -b",
|
||||
"verify": "yarn --cwd=../.. verify",
|
||||
"verify:dir": "npx eslint . --max-warnings 0"
|
||||
},
|
||||
"sideEffects": false,
|
||||
"dependencies": {
|
||||
"@google-cloud/monitoring": "4.0.0",
|
||||
"@google-cloud/secret-manager": "4.2.1",
|
||||
"@tiptap/core": "2.3.2",
|
||||
"@tiptap/html": "2.3.2",
|
||||
"colors": "1.4.0",
|
||||
"dayjs": "1.11.4",
|
||||
"firebase-admin": "11.11.1",
|
||||
"gcp-metadata": "6.1.0",
|
||||
"lodash": "4.17.21",
|
||||
"pg-promise": "11.4.1",
|
||||
"posthog-node": "4.11.0",
|
||||
"string-similarity": "4.0.4"
|
||||
}
|
||||
}
|
||||
59
backend/shared/src/analytics.ts
Normal file
59
backend/shared/src/analytics.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Request } from 'express'
|
||||
import { trackAuditEvent } from 'shared/audit-events'
|
||||
import { PostHog } from 'posthog-node'
|
||||
import { isProd, log } from 'shared/utils'
|
||||
import { PROD_CONFIG } from 'common/envs/prod'
|
||||
import { DEV_CONFIG } from 'common/envs/dev'
|
||||
|
||||
const key = isProd() ? PROD_CONFIG.posthogKey : DEV_CONFIG.posthogKey
|
||||
|
||||
const client = new PostHog(key, {
|
||||
host: 'https://us.i.posthog.com',
|
||||
flushAt: 1,
|
||||
flushInterval: 0,
|
||||
})
|
||||
|
||||
export const track = async (
|
||||
userId: string,
|
||||
eventName: string,
|
||||
properties?: any
|
||||
) => {
|
||||
try {
|
||||
client.capture({
|
||||
distinctId: userId,
|
||||
event: eventName,
|
||||
properties,
|
||||
})
|
||||
} catch (e) {
|
||||
log.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
export const trackPublicEvent = async (
|
||||
userId: string,
|
||||
eventName: string,
|
||||
properties?: any
|
||||
) => {
|
||||
const allProperties = Object.assign(properties ?? {}, {})
|
||||
const { commentId, ...data } = allProperties
|
||||
try {
|
||||
client.capture({
|
||||
distinctId: userId,
|
||||
event: eventName,
|
||||
properties,
|
||||
})
|
||||
await trackAuditEvent(userId, eventName, commentId, data)
|
||||
} catch (e) {
|
||||
log.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
export const getIp = (req: Request) => {
|
||||
const xForwarded = req.headers['x-forwarded-for']
|
||||
const xForwardedIp = Array.isArray(xForwarded) ? xForwarded[0] : xForwarded
|
||||
const ip = xForwardedIp ?? req.socket.remoteAddress ?? req.ip
|
||||
if (ip?.includes(',')) {
|
||||
return ip.split(',')[0].trim()
|
||||
}
|
||||
return ip ?? ''
|
||||
}
|
||||
18
backend/shared/src/audit-events.ts
Normal file
18
backend/shared/src/audit-events.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { createSupabaseDirectClient } from 'shared/supabase/init'
|
||||
import { tryOrLogError } from 'shared/helpers/try-or-log-error'
|
||||
|
||||
export const trackAuditEvent = async (
|
||||
userId: string,
|
||||
name: string,
|
||||
commentId?: string,
|
||||
otherProps?: Record<string, any>
|
||||
) => {
|
||||
const pg = createSupabaseDirectClient()
|
||||
return await tryOrLogError(
|
||||
pg.none(
|
||||
`insert into audit_events (name, user_id, contract_id, comment_id, data)
|
||||
values ($1, $2, '', $3, $4) on conflict do nothing`,
|
||||
[name, userId, commentId, otherProps]
|
||||
)
|
||||
)
|
||||
}
|
||||
91
backend/shared/src/create-love-notification.ts
Normal file
91
backend/shared/src/create-love-notification.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Row } from 'common/supabase/utils'
|
||||
import { getPrivateUser, getUser } from './utils'
|
||||
import { createSupabaseDirectClient } from './supabase/init'
|
||||
import { getNotificationDestinationsForUser } from 'common/user-notification-preferences'
|
||||
import { Notification } from 'common/notifications'
|
||||
import { insertNotificationToSupabase } from './supabase/notifications'
|
||||
import { getLover } from './love/supabase'
|
||||
|
||||
export const createLoveLikeNotification = async (like: Row<'love_likes'>) => {
|
||||
const { creator_id, target_id, like_id } = like
|
||||
|
||||
const targetPrivateUser = await getPrivateUser(target_id)
|
||||
const lover = await getLover(creator_id)
|
||||
|
||||
if (!targetPrivateUser || !lover) return
|
||||
|
||||
const { sendToBrowser } = getNotificationDestinationsForUser(
|
||||
targetPrivateUser,
|
||||
'new_love_like'
|
||||
)
|
||||
if (!sendToBrowser) return
|
||||
|
||||
const id = `${creator_id}-${like_id}`
|
||||
const notification: Notification = {
|
||||
id,
|
||||
userId: target_id,
|
||||
reason: 'new_love_like',
|
||||
createdTime: Date.now(),
|
||||
isSeen: false,
|
||||
sourceId: like_id,
|
||||
sourceType: 'love_like',
|
||||
sourceUpdateType: 'created',
|
||||
sourceUserName: lover.user.name,
|
||||
sourceUserUsername: lover.user.username,
|
||||
sourceUserAvatarUrl: lover.pinned_url ?? lover.user.avatarUrl,
|
||||
sourceText: '',
|
||||
}
|
||||
const pg = createSupabaseDirectClient()
|
||||
return await insertNotificationToSupabase(notification, pg)
|
||||
}
|
||||
|
||||
export const createLoveShipNotification = async (
|
||||
ship: Row<'love_ships'>,
|
||||
recipientId: string
|
||||
) => {
|
||||
const { creator_id, target1_id, target2_id, ship_id } = ship
|
||||
const otherTargetId = target1_id === recipientId ? target2_id : target1_id
|
||||
|
||||
const creator = await getUser(creator_id)
|
||||
const targetPrivateUser = await getPrivateUser(recipientId)
|
||||
const lover = await getLover(otherTargetId)
|
||||
|
||||
if (!creator || !targetPrivateUser || !lover) {
|
||||
console.error('Could not load user object', {
|
||||
creator,
|
||||
targetPrivateUser,
|
||||
lover,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const { sendToBrowser } = getNotificationDestinationsForUser(
|
||||
targetPrivateUser,
|
||||
'new_love_ship'
|
||||
)
|
||||
if (!sendToBrowser) return
|
||||
|
||||
const id = `${creator_id}-${ship_id}`
|
||||
const notification: Notification = {
|
||||
id,
|
||||
userId: recipientId,
|
||||
reason: 'new_love_ship',
|
||||
createdTime: Date.now(),
|
||||
isSeen: false,
|
||||
sourceId: ship_id,
|
||||
sourceType: 'love_ship',
|
||||
sourceUpdateType: 'created',
|
||||
sourceUserName: lover.user.name,
|
||||
sourceUserUsername: lover.user.username,
|
||||
sourceUserAvatarUrl: lover.pinned_url ?? lover.user.avatarUrl,
|
||||
sourceText: '',
|
||||
data: {
|
||||
creatorId: creator_id,
|
||||
creatorName: creator.name,
|
||||
creatorUsername: creator.username,
|
||||
otherTargetId,
|
||||
},
|
||||
}
|
||||
const pg = createSupabaseDirectClient()
|
||||
return await insertNotificationToSupabase(notification, pg)
|
||||
}
|
||||
11
backend/shared/src/helpers/auth.ts
Normal file
11
backend/shared/src/helpers/auth.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { APIError } from 'common/api/utils'
|
||||
import { isAdminId, isModId } from 'common/envs/constants'
|
||||
|
||||
export const throwErrorIfNotMod = async (userId: string) => {
|
||||
if (!isAdminId(userId) && !isModId(userId)) {
|
||||
throw new APIError(
|
||||
403,
|
||||
`User ${userId} must be an admin or trusted to perform this action.`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Storage } from 'firebase-admin/storage'
|
||||
import { DOMAIN } from 'common/envs/constants'
|
||||
|
||||
type Bucket = ReturnType<InstanceType<typeof Storage>['bucket']>
|
||||
|
||||
export const generateAvatarUrl = async (
|
||||
userId: string,
|
||||
name: string,
|
||||
bucket: Bucket
|
||||
) => {
|
||||
const backgroundColors = [
|
||||
'#FF8C00',
|
||||
'#800080',
|
||||
'#00008B',
|
||||
'#008000',
|
||||
'#A52A2A',
|
||||
'#555555',
|
||||
'#008080',
|
||||
]
|
||||
const imageUrl = `https://ui-avatars.com/api/?name=${encodeURIComponent(
|
||||
name
|
||||
)}&background=${encodeURIComponent(
|
||||
backgroundColors[Math.floor(Math.random() * backgroundColors.length)]
|
||||
)}&color=fff&size=256&format=png`
|
||||
try {
|
||||
const res = await fetch(imageUrl)
|
||||
const buffer = await res.arrayBuffer()
|
||||
return await upload(userId, Buffer.from(buffer), bucket)
|
||||
} catch (e) {
|
||||
console.log('error generating avatar', e)
|
||||
return `https://${DOMAIN}/images/default-avatar.png`
|
||||
}
|
||||
}
|
||||
|
||||
async function upload(userId: string, buffer: Buffer, bucket: Bucket) {
|
||||
const filename = `user-images/${userId}.png`
|
||||
let file = bucket.file(filename)
|
||||
|
||||
const exists = await file.exists()
|
||||
if (exists[0]) {
|
||||
await file.delete()
|
||||
file = bucket.file(filename)
|
||||
}
|
||||
await file.save(buffer, {
|
||||
private: false,
|
||||
public: true,
|
||||
metadata: { contentType: 'image/png' },
|
||||
})
|
||||
return `https://storage.googleapis.com/${bucket.cloudStorageURI.hostname}/${filename}`
|
||||
}
|
||||
20
backend/shared/src/helpers/search.ts
Normal file
20
backend/shared/src/helpers/search.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export const constructPrefixTsQuery = (term: string) => {
|
||||
const sanitized = term
|
||||
.replace(/'/g, "''")
|
||||
.replace(/[!&|():*<>]/g, '')
|
||||
.trim()
|
||||
console.log(`Term: "${sanitized}"`)
|
||||
if (sanitized === '') return ''
|
||||
const tokens = sanitized.split(/\s+/)
|
||||
return tokens.join(' & ') + ':*'
|
||||
}
|
||||
|
||||
export const constructIlikeQuery = (term: string) => {
|
||||
const sanitized = term
|
||||
.replace(/'/g, "''")
|
||||
.replace(/[_%()<>]/g, '')
|
||||
.trim()
|
||||
|
||||
if (sanitized === '') return ''
|
||||
return '%' + sanitized + '%' // ideally we'd do prefix but many groups have leading emojis
|
||||
}
|
||||
10
backend/shared/src/helpers/try-or-log-error.ts
Normal file
10
backend/shared/src/helpers/try-or-log-error.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { log } from 'shared/utils'
|
||||
|
||||
export const tryOrLogError = async <T>(task: Promise<T>) => {
|
||||
try {
|
||||
return await task
|
||||
} catch (e) {
|
||||
log.error(e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user