Pull up features from manifold.love

This commit is contained in:
MartinBraquet
2025-08-27 21:30:05 +02:00
parent 078893f7d1
commit 53432520cd
582 changed files with 64062 additions and 9624 deletions

View File

@@ -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
View File

@@ -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
View 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"
}
}
]
}

1
.yarnrc Normal file
View File

@@ -0,0 +1 @@
save-exact true

View File

@@ -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
View 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.

View File

@@ -1,11 +1,11 @@
[![CI](https://github.com/BayesBond/BayesBond/actions/workflows/ci.yml/badge.svg)](https://github.com/BayesBond/BayesBond/actions/workflows/ci.yml)
[![CD](https://github.com/BayesBond/BayesBond/actions/workflows/cd.yml/badge.svg)](https://github.com/BayesBond/BayesBond/actions/workflows/cd.yml)
[![CI](https://github.com/CompassMeet/Compass/actions/workflows/ci.yml/badge.svg)](https://github.com/CompassMeet/Compass/actions/workflows/ci.yml)
[![CD](https://github.com/CompassMeet/Compass/actions/workflows/cd.yml/badge.svg)](https://github.com/CompassMeet/Compass/actions/workflows/cd.yml)
![Vercel](https://deploy-badge.vercel.app/vercel/bayesbond)
# 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
View File

@@ -0,0 +1,7 @@
# Supabase
.branches
.temp
.env
# regen-schema
**/dump.sql

50
backend/api/.eslintrc.js Normal file
View 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'],
},
}

View File

@@ -0,0 +1,6 @@
.gitignore
.gcloudignore
/tsconfig.json
/deploy.sh
/src/
/lib/

16
backend/api/.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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}"

View 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
View 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
View 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
View 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`,
})
}
})

View 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')
}

View 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),
})
})
}

View 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,
}
}

View 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)
}
}

View 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 }
}

View 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
}

View 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) }
}

View 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'
)
}

View 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',
]

View 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(),
})
}

View 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,
}
}

View 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
}

View 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,
}
}

View 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,
}
}

View 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 }
}

View 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 })
}

View 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
)
}

View 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]
)
}

View 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,
}),
}
}

View 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)
}

View 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
}

View 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,
}
}

View 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
}

View 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))
}

View 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)
}

View 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) }
}

View 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,
}
}

View 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]
)
}

View 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
View 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 }
}

View 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 }
}
}

View 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
}

View 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
View 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()

View 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,
}
}

View 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' }
}

View 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
}

View 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 })
}

View 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)
}
}

View 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
View 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"]
}

View 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
View File

@@ -0,0 +1,10 @@
# Compiled JavaScript files
lib/
# TypeScript v1 declaration files
typings/
# Node.js dependency directory
node_modules/
package-lock.json

View 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" />,
})
}

View 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,
}

View 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
}

View 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))
}

View 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

View 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

View 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

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View 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

View 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.

View 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
View 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

View 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
View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
{
"projects": {
"default": "polylove",
"prod": "polylove",
"dev": "polylove-dev"
}
}

View File

@@ -0,0 +1,12 @@
{
"storage": [
{
"bucket": "polylove.firebasestorage.app",
"rules": "storage.rules"
},
{
"bucket": "polylove-private.firebasestorage.app",
"rules": "private-storage.rules"
}
]
}

View 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
}
}
}

View 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
}
}
}

View 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;

View 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`
)
})

View 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

View 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))
})
}

View 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);

View 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"
)

View 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`
)
}

View 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)
}
})

View 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()
}

View 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
}

View 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
View File

@@ -0,0 +1,2 @@
# Compiled JavaScript files
lib/

View 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"
}
}

View 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 ?? ''
}

View 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]
)
)
}

View 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)
}

View 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.`
)
}
}

View File

@@ -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}`
}

View 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
}

View 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