diff --git a/.env.example b/.env.example index 1c5b109..2696554 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore index e3f8983..111011e 100644 --- a/.gitignore +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..dfb3cd7 --- /dev/null +++ b/.prettierrc @@ -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" + } + } + ] +} diff --git a/.yarnrc b/.yarnrc new file mode 100644 index 0000000..4c34e7a --- /dev/null +++ b/.yarnrc @@ -0,0 +1 @@ +save-exact true \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c5c6a24..861b9e1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..9441cd3 --- /dev/null +++ b/LICENSE-MIT @@ -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. diff --git a/README.md b/README.md index 8c6160c..002719e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..fba7b36 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,7 @@ +# Supabase +.branches +.temp +.env + +# regen-schema +**/dump.sql diff --git a/backend/api/.eslintrc.js b/backend/api/.eslintrc.js new file mode 100644 index 0000000..dd39bc3 --- /dev/null +++ b/backend/api/.eslintrc.js @@ -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'], + }, +} diff --git a/backend/api/.gcloudignore b/backend/api/.gcloudignore new file mode 100644 index 0000000..ee0535f --- /dev/null +++ b/backend/api/.gcloudignore @@ -0,0 +1,6 @@ +.gitignore +.gcloudignore +/tsconfig.json +/deploy.sh +/src/ +/lib/ diff --git a/backend/api/.gitignore b/backend/api/.gitignore new file mode 100644 index 0000000..9c15e50 --- /dev/null +++ b/backend/api/.gitignore @@ -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 diff --git a/backend/api/Dockerfile b/backend/api/Dockerfile new file mode 100644 index 0000000..bdac438 --- /dev/null +++ b/backend/api/Dockerfile @@ -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"] diff --git a/backend/api/README.md b/backend/api/README.md new file mode 100644 index 0000000..a6d818f --- /dev/null +++ b/backend/api/README.md @@ -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. diff --git a/backend/api/debug.sh b/backend/api/debug.sh new file mode 100755 index 0000000..f44f51b --- /dev/null +++ b/backend/api/debug.sh @@ -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 diff --git a/backend/api/deploy-api.sh b/backend/api/deploy-api.sh new file mode 100755 index 0000000..eb95dc0 --- /dev/null +++ b/backend/api/deploy-api.sh @@ -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}" diff --git a/backend/api/ecosystem.config.js b/backend/api/ecosystem.config.js new file mode 100644 index 0000000..2b2a941 --- /dev/null +++ b/backend/api/ecosystem.config.js @@ -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, + }, + }, + ], +} diff --git a/backend/api/main.tf b/backend/api/main.tf new file mode 100644 index 0000000..0952d31 --- /dev/null +++ b/backend/api/main.tf @@ -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 = <=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" + } +} diff --git a/backend/api/src/app.ts b/backend/api/src/app.ts new file mode 100644 index 0000000..19f843a --- /dev/null +++ b/backend/api/src/app.ts @@ -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 } = { + 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`, + }) + } +}) diff --git a/backend/api/src/ban-user.ts b/backend/api/src/ban-user.ts new file mode 100644 index 0000000..dd55933 --- /dev/null +++ b/backend/api/src/ban-user.ts @@ -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') +} diff --git a/backend/api/src/block-user.ts b/backend/api/src/block-user.ts new file mode 100644 index 0000000..4d77656 --- /dev/null +++ b/backend/api/src/block-user.ts @@ -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), + }) + }) +} diff --git a/backend/api/src/compatible-lovers.ts b/backend/api/src/compatible-lovers.ts new file mode 100644 index 0000000..0953d4a --- /dev/null +++ b/backend/api/src/compatible-lovers.ts @@ -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, + } +} diff --git a/backend/api/src/create-comment.ts b/backend/api/src/create-comment.ts new file mode 100644 index 0000000..5a8c30e --- /dev/null +++ b/backend/api/src/create-comment.ts @@ -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>( + `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) + } +} diff --git a/backend/api/src/create-compatibility-question.ts b/backend/api/src/create-compatibility-question.ts new file mode 100644 index 0000000..4fa15d2 --- /dev/null +++ b/backend/api/src/create-compatibility-question.ts @@ -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 } +} diff --git a/backend/api/src/create-lover.ts b/backend/api/src/create-lover.ts new file mode 100644 index 0000000..1ab838b --- /dev/null +++ b/backend/api/src/create-lover.ts @@ -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 +} diff --git a/backend/api/src/create-private-user-message-channel.ts b/backend/api/src/create-private-user-message-channel.ts new file mode 100644 index 0000000..d183d5d --- /dev/null +++ b/backend/api/src/create-private-user-message-channel.ts @@ -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) } +} diff --git a/backend/api/src/create-private-user-message.ts b/backend/api/src/create-private-user-message.ts new file mode 100644 index 0000000..d9399c7 --- /dev/null +++ b/backend/api/src/create-private-user-message.ts @@ -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' + ) +} diff --git a/backend/api/src/create-user.ts b/backend/api/src/create-user.ts new file mode 100644 index 0000000..76492ee --- /dev/null +++ b/backend/api/src/create-user.ts @@ -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( + `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', +] diff --git a/backend/api/src/delete-me.ts b/backend/api/src/delete-me.ts new file mode 100644 index 0000000..ac7f6d4 --- /dev/null +++ b/backend/api/src/delete-me.ts @@ -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(), + }) +} diff --git a/backend/api/src/get-compatibililty-questions.ts b/backend/api/src/get-compatibililty-questions.ts new file mode 100644 index 0000000..5db8c83 --- /dev/null +++ b/backend/api/src/get-compatibililty-questions.ts @@ -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, + } +} diff --git a/backend/api/src/get-current-private-user.ts b/backend/api/src/get-current-private-user.ts new file mode 100644 index 0000000..296aedf --- /dev/null +++ b/backend/api/src/get-current-private-user.ts @@ -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>( + '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 +} diff --git a/backend/api/src/get-likes-and-ships.ts b/backend/api/src/get-likes-and-ships.ts new file mode 100644 index 0000000..4c4c293 --- /dev/null +++ b/backend/api/src/get-likes-and-ships.ts @@ -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, + } +} diff --git a/backend/api/src/get-lover-answers.ts b/backend/api/src/get-lover-answers.ts new file mode 100644 index 0000000..0e364c6 --- /dev/null +++ b/backend/api/src/get-lover-answers.ts @@ -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>( + `select * from love_compatibility_answers + where + creator_id = $1 + order by created_time desc + `, + [userId] + ) + + return { + status: 'success', + answers, + } +} diff --git a/backend/api/src/get-lovers.ts b/backend/api/src/get-lovers.ts new file mode 100644 index 0000000..5ed6277 --- /dev/null +++ b/backend/api/src/get-lovers.ts @@ -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 } +} diff --git a/backend/api/src/get-me.ts b/backend/api/src/get-me.ts new file mode 100644 index 0000000..ab58e57 --- /dev/null +++ b/backend/api/src/get-me.ts @@ -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 }) +} diff --git a/backend/api/src/get-notifications.ts b/backend/api/src/get-notifications.ts new file mode 100644 index 0000000..d7a889f --- /dev/null +++ b/backend/api/src/get-notifications.ts @@ -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 + ) +} diff --git a/backend/api/src/get-private-messages.ts b/backend/api/src/get-private-messages.ts new file mode 100644 index 0000000..4a48033 --- /dev/null +++ b/backend/api/src/get-private-messages.ts @@ -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] + ) +} diff --git a/backend/api/src/get-supabase-token.ts b/backend/api/src/get-supabase-token.ts new file mode 100644 index 0000000..4f57098 --- /dev/null +++ b/backend/api/src/get-supabase-token.ts @@ -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, + }), + } +} diff --git a/backend/api/src/get-user.ts b/backend/api/src/get-user.ts new file mode 100644 index 0000000..8c9c4bc --- /dev/null +++ b/backend/api/src/get-user.ts @@ -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) +} diff --git a/backend/api/src/has-free-like.ts b/backend/api/src/has-free-like.ts new file mode 100644 index 0000000..4401484 --- /dev/null +++ b/backend/api/src/has-free-like.ts @@ -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( + ` + 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 +} diff --git a/backend/api/src/health.ts b/backend/api/src/health.ts new file mode 100644 index 0000000..8b5d654 --- /dev/null +++ b/backend/api/src/health.ts @@ -0,0 +1,8 @@ +import { APIHandler } from './helpers/endpoint' + +export const health: APIHandler<'health'> = async (_, auth) => { + return { + message: 'Server is working.', + uid: auth?.uid, + } +} diff --git a/backend/api/src/helpers/endpoint.ts b/backend/api/src/helpers/endpoint.ts new file mode 100644 index 0000000..54be977 --- /dev/null +++ b/backend/api/src/helpers/endpoint.ts @@ -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 | Json[] +export type JsonHandler = ( + req: Request, + res: Response +) => Promise +export type AuthedHandler = ( + req: Request, + user: AuthedUser, + res: Response +) => Promise +export type MaybeAuthedHandler = ( + req: Request, + user: AuthedUser | undefined, + res: Response +) => Promise + +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 => { + 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 => { + 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 = (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 + } +} + +export const jsonEndpoint = (fn: JsonHandler) => { + return async (req: Request, res: Response, next: NextFunction) => { + try { + res.status(200).json(await fn(req, res)) + } catch (e) { + next(e) + } + } +} + +export const authEndpoint = (fn: AuthedHandler) => { + 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 = ( + fn: MaybeAuthedHandler +) => { + 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 = ( + props: ValidatedAPIParams, + auth: APISchema extends { authed: true } + ? AuthedUser + : AuthedUser | undefined, + req: Request +) => Promise> + +export const typedEndpoint = ( + name: N, + handler: APIHandler +) => { + 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 +} diff --git a/backend/api/src/hide-comment.ts b/backend/api/src/hide-comment.ts new file mode 100644 index 0000000..e5f85a8 --- /dev/null +++ b/backend/api/src/hide-comment.ts @@ -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>( + `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)) +} diff --git a/backend/api/src/junk-drawer/private-messages.ts b/backend/api/src/junk-drawer/private-messages.ts new file mode 100644 index 0000000..5e6a350 --- /dev/null +++ b/backend/api/src/junk-drawer/private-messages.ts @@ -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( + `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) +} diff --git a/backend/api/src/leave-private-user-message-channel.ts b/backend/api/src/leave-private-user-message-channel.ts new file mode 100644 index 0000000..f10ec49 --- /dev/null +++ b/backend/api/src/leave-private-user-message-channel.ts @@ -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) } +} diff --git a/backend/api/src/like-lover.ts b/backend/api/src/like-lover.ts new file mode 100644 index 0000000..683b98d --- /dev/null +++ b/backend/api/src/like-lover.ts @@ -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>( + '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>( + '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, + } +} diff --git a/backend/api/src/mark-all-notifications-read.ts b/backend/api/src/mark-all-notifications-read.ts new file mode 100644 index 0000000..7d0c0e8 --- /dev/null +++ b/backend/api/src/mark-all-notifications-read.ts @@ -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] + ) +} diff --git a/backend/api/src/remove-pinned-photo.ts b/backend/api/src/remove-pinned-photo.ts new file mode 100644 index 0000000..e3764ab --- /dev/null +++ b/backend/api/src/remove-pinned-photo.ts @@ -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, + } +} diff --git a/backend/api/src/report.ts b/backend/api/src/report.ts new file mode 100644 index 0000000..1dfdc35 --- /dev/null +++ b/backend/api/src/report.ts @@ -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 } +} diff --git a/backend/api/src/search-location.ts b/backend/api/src/search-location.ts new file mode 100644 index 0000000..323c3ae --- /dev/null +++ b/backend/api/src/search-location.ts @@ -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 } + } +} diff --git a/backend/api/src/search-near-city.ts b/backend/api/src/search-near-city.ts new file mode 100644 index 0000000..21c0609 --- /dev/null +++ b/backend/api/src/search-near-city.ts @@ -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 +} diff --git a/backend/api/src/search-users.ts b/backend/api/src/search-users.ts new file mode 100644 index 0000000..d609838 --- /dev/null +++ b/backend/api/src/search-users.ts @@ -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) + ) +} diff --git a/backend/api/src/serve.ts b/backend/api/src/serve.ts new file mode 100644 index 0000000..ed9aec3 --- /dev/null +++ b/backend/api/src/serve.ts @@ -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() diff --git a/backend/api/src/ship-lovers.ts b/backend/api/src/ship-lovers.ts new file mode 100644 index 0000000..fa97c71 --- /dev/null +++ b/backend/api/src/ship-lovers.ts @@ -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, + } +} diff --git a/backend/api/src/star-lover.ts b/backend/api/src/star-lover.ts new file mode 100644 index 0000000..91c05b9 --- /dev/null +++ b/backend/api/src/star-lover.ts @@ -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>( + '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' } +} diff --git a/backend/api/src/update-lover.ts b/backend/api/src/update-lover.ts new file mode 100644 index 0000000..4d9ad00 --- /dev/null +++ b/backend/api/src/update-lover.ts @@ -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>('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 +} diff --git a/backend/api/src/update-me.ts b/backend/api/src/update-me.ts new file mode 100644 index 0000000..6f8613a --- /dev/null +++ b/backend/api/src/update-me.ts @@ -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 }) +} diff --git a/backend/api/src/update-notif-setting.ts b/backend/api/src/update-notif-setting.ts new file mode 100644 index 0000000..6a75fb9 --- /dev/null +++ b/backend/api/src/update-notif-setting.ts @@ -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) + } +} diff --git a/backend/api/src/update-private-user-message-channel.ts b/backend/api/src/update-private-user-message-channel.ts new file mode 100644 index 0000000..584d7a1 --- /dev/null +++ b/backend/api/src/update-private-user-message-channel.ts @@ -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) } +} diff --git a/backend/api/tsconfig.json b/backend/api/tsconfig.json new file mode 100644 index 0000000..65419a6 --- /dev/null +++ b/backend/api/tsconfig.json @@ -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"] +} diff --git a/backend/api/url-map-config.yaml b/backend/api/url-map-config.yaml new file mode 100644 index 0000000..be8610d --- /dev/null +++ b/backend/api/url-map-config.yaml @@ -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 diff --git a/backend/email/.gitignore b/backend/email/.gitignore new file mode 100644 index 0000000..88f8897 --- /dev/null +++ b/backend/email/.gitignore @@ -0,0 +1,10 @@ +# Compiled JavaScript files +lib/ + +# TypeScript v1 declaration files +typings/ + +# Node.js dependency directory +node_modules/ + +package-lock.json \ No newline at end of file diff --git a/backend/email/emails/functions/helpers.tsx b/backend/email/emails/functions/helpers.tsx new file mode 100644 index 0000000..534e363 --- /dev/null +++ b/backend/email/emails/functions/helpers.tsx @@ -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 ' + +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: ( + + ), + }) +} + +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: ( + + ), + }) +} + +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: ( + + ), + }) +} + +export const sendTestEmail = async (toEmail: string) => { + return await sendEmail({ + from, + subject: 'Test email from Love', + to: toEmail, + react: , + }) +} diff --git a/backend/email/emails/functions/mock.ts b/backend/email/emails/functions/mock.ts new file mode 100644 index 0000000..10b1570 --- /dev/null +++ b/backend/email/emails/functions/mock.ts @@ -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, +} diff --git a/backend/email/emails/functions/send-email.ts b/backend/email/emails/functions/send-email.ts new file mode 100644 index 0000000..06753fc --- /dev/null +++ b/backend/email/emails/functions/send-email.ts @@ -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 +} diff --git a/backend/email/emails/functions/send-test-email.ts b/backend/email/emails/functions/send-test-email.ts new file mode 100755 index 0000000..c4e8188 --- /dev/null +++ b/backend/email/emails/functions/send-test-email.ts @@ -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)) +} \ No newline at end of file diff --git a/backend/email/emails/new-endorsement.tsx b/backend/email/emails/new-endorsement.tsx new file mode 100644 index 0000000..9ab5879 --- /dev/null +++ b/backend/email/emails/new-endorsement.tsx @@ -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 ( + + + New endorsement from {fromUser.name} + + +
+ manifold.love +
+ +
+ Hi {name}, + + {fromUser.name} just endorsed you! + +
+ + + + + + "{endorsementText}" + + + + +
+
+ +
+ + This e-mail has been sent to {name},{' '} + {/* + click here to unsubscribe from this type of notification + + . */} + +
+
+ + + ) +} + +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 diff --git a/backend/email/emails/new-match.tsx b/backend/email/emails/new-match.tsx new file mode 100644 index 0000000..d385a76 --- /dev/null +++ b/backend/email/emails/new-match.tsx @@ -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 ( + + + You have a new match! + + +
+ manifold.love +
+ +
+ Hi {name}, + + + {matchedWithUser.name} just matched with you! + + +
+ + + + + +
+
+ +
+ + This e-mail has been sent to {name},{' '} + {/* + click here to unsubscribe from this type of notification + + . */} + +
+
+ + + ) +} + +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 diff --git a/backend/email/emails/new-message.tsx b/backend/email/emails/new-message.tsx new file mode 100644 index 0000000..3ec643f --- /dev/null +++ b/backend/email/emails/new-message.tsx @@ -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 ( + + + New message from {creatorName} + + +
+ manifold.love +
+ +
+ Hi {name}, + + {creatorName} just messaged you! + +
+ + {`${creatorName}'s + + + +
+
+ +
+ + This e-mail has been sent to {name},{' '} + {/* + click here to unsubscribe from this type of notification + + . */} + +
+
+ + + ) +} + +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 diff --git a/backend/email/emails/static/manifold-love-banner.png b/backend/email/emails/static/manifold-love-banner.png new file mode 100644 index 0000000..3e6bebd Binary files /dev/null and b/backend/email/emails/static/manifold-love-banner.png differ diff --git a/backend/email/emails/test.tsx b/backend/email/emails/test.tsx new file mode 100644 index 0000000..89eda5f --- /dev/null +++ b/backend/email/emails/test.tsx @@ -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 ( + + + Helloo {props.name} + + Hello {props.name} + + + ) +} + +Test.PreviewProps = { + name: 'Clarity', +} + +export default Test diff --git a/backend/email/knowledge.md b/backend/email/knowledge.md new file mode 100644 index 0000000..6a8df32 --- /dev/null +++ b/backend/email/knowledge.md @@ -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. diff --git a/backend/email/package.json b/backend/email/package.json new file mode 100644 index 0000000..94a6e81 --- /dev/null +++ b/backend/email/package.json @@ -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" + } +} diff --git a/backend/email/readme.md b/backend/email/readme.md new file mode 100644 index 0000000..fc875bf --- /dev/null +++ b/backend/email/readme.md @@ -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 diff --git a/backend/email/tsconfig.json b/backend/email/tsconfig.json new file mode 100644 index 0000000..6a40547 --- /dev/null +++ b/backend/email/tsconfig.json @@ -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"] +} diff --git a/backend/email/yarn.lock b/backend/email/yarn.lock new file mode 100644 index 0000000..81d7337 --- /dev/null +++ b/backend/email/yarn.lock @@ -0,0 +1,1776 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@ampproject/remapping@^2.2.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.3.0.tgz#ed441b6fa600072520ce18b43d2c8cc8caecc7f4" + integrity sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw== + dependencies: + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.24" + +"@babel/code-frame@^7.24.2", "@babel/code-frame@^7.26.2": + version "7.26.2" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.26.2.tgz#4b5fab97d33338eff916235055f0ebc21e573a85" + integrity sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ== + dependencies: + "@babel/helper-validator-identifier" "^7.25.9" + js-tokens "^4.0.0" + picocolors "^1.0.0" + +"@babel/compat-data@^7.26.5": + version "7.26.8" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.26.8.tgz#821c1d35641c355284d4a870b8a4a7b0c141e367" + integrity sha512-oH5UPLMWR3L2wEFLnFJ1TZXqHufiTKAiLfqw5zkhS4dKXLJ10yVztfil/twG8EDTA4F/tvVNw9nOl4ZMslB8rQ== + +"@babel/core@7.24.5": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.24.5.tgz#15ab5b98e101972d171aeef92ac70d8d6718f06a" + integrity sha512-tVQRucExLQ02Boi4vdPp49svNGcfL2GhdTCT9aldhXgCJVAI21EtRfBettiuLUwce/7r6bFdgs6JFkcdTiFttA== + dependencies: + "@ampproject/remapping" "^2.2.0" + "@babel/code-frame" "^7.24.2" + "@babel/generator" "^7.24.5" + "@babel/helper-compilation-targets" "^7.23.6" + "@babel/helper-module-transforms" "^7.24.5" + "@babel/helpers" "^7.24.5" + "@babel/parser" "^7.24.5" + "@babel/template" "^7.24.0" + "@babel/traverse" "^7.24.5" + "@babel/types" "^7.24.5" + convert-source-map "^2.0.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.2.3" + semver "^6.3.1" + +"@babel/generator@^7.24.5", "@babel/generator@^7.26.9": + version "7.26.9" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.26.9.tgz#75a9482ad3d0cc7188a537aa4910bc59db67cbca" + integrity sha512-kEWdzjOAUMW4hAyrzJ0ZaTOu9OmpyDIQicIh0zg0EEcEkYXZb2TjtBhnHi2ViX7PKwZqF4xwqfAm299/QMP3lg== + dependencies: + "@babel/parser" "^7.26.9" + "@babel/types" "^7.26.9" + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.25" + jsesc "^3.0.2" + +"@babel/helper-compilation-targets@^7.23.6": + version "7.26.5" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.26.5.tgz#75d92bb8d8d51301c0d49e52a65c9a7fe94514d8" + integrity sha512-IXuyn5EkouFJscIDuFF5EsiSolseme1s0CZB+QxVugqJLYmKdxI1VfIBOst0SUu4rnk2Z7kqTwmoO1lp3HIfnA== + dependencies: + "@babel/compat-data" "^7.26.5" + "@babel/helper-validator-option" "^7.25.9" + browserslist "^4.24.0" + lru-cache "^5.1.1" + semver "^6.3.1" + +"@babel/helper-module-imports@^7.25.9": + version "7.25.9" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz#e7f8d20602ebdbf9ebbea0a0751fb0f2a4141715" + integrity sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw== + dependencies: + "@babel/traverse" "^7.25.9" + "@babel/types" "^7.25.9" + +"@babel/helper-module-transforms@^7.24.5": + version "7.26.0" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz#8ce54ec9d592695e58d84cd884b7b5c6a2fdeeae" + integrity sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw== + dependencies: + "@babel/helper-module-imports" "^7.25.9" + "@babel/helper-validator-identifier" "^7.25.9" + "@babel/traverse" "^7.25.9" + +"@babel/helper-string-parser@^7.25.9": + version "7.25.9" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz#1aabb72ee72ed35789b4bbcad3ca2862ce614e8c" + integrity sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA== + +"@babel/helper-validator-identifier@^7.25.9": + version "7.25.9" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz#24b64e2c3ec7cd3b3c547729b8d16871f22cbdc7" + integrity sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ== + +"@babel/helper-validator-option@^7.25.9": + version "7.25.9" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz#86e45bd8a49ab7e03f276577f96179653d41da72" + integrity sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw== + +"@babel/helpers@^7.24.5": + version "7.26.9" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.26.9.tgz#28f3fb45252fc88ef2dc547c8a911c255fc9fef6" + integrity sha512-Mz/4+y8udxBKdmzt/UjPACs4G3j5SshJJEFFKxlCGPydG4JAHXxjWjAwjd09tf6oINvl1VfMJo+nB7H2YKQ0dA== + dependencies: + "@babel/template" "^7.26.9" + "@babel/types" "^7.26.9" + +"@babel/parser@7.24.5": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.24.5.tgz#4a4d5ab4315579e5398a82dcf636ca80c3392790" + integrity sha512-EOv5IK8arwh3LI47dz1b0tKUb/1uhHAnHJOrjgtQMIpu1uXd9mlFrJg9IUgGUgZ41Ch0K8REPTYpO7B76b4vJg== + +"@babel/parser@^7.24.5", "@babel/parser@^7.26.9": + version "7.26.9" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.26.9.tgz#d9e78bee6dc80f9efd8f2349dcfbbcdace280fd5" + integrity sha512-81NWa1njQblgZbQHxWHpxxCzNsa3ZwvFqpUg7P+NNUU6f3UU2jBEg4OlF/J6rl8+PQGh1q6/zWScd001YwcA5A== + dependencies: + "@babel/types" "^7.26.9" + +"@babel/template@^7.24.0", "@babel/template@^7.26.9": + version "7.26.9" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.26.9.tgz#4577ad3ddf43d194528cff4e1fa6b232fa609bb2" + integrity sha512-qyRplbeIpNZhmzOysF/wFMuP9sctmh2cFzRAZOn1YapxBsE1i9bJIY586R/WBLfLcmcBlM8ROBiQURnnNy+zfA== + dependencies: + "@babel/code-frame" "^7.26.2" + "@babel/parser" "^7.26.9" + "@babel/types" "^7.26.9" + +"@babel/traverse@^7.24.5", "@babel/traverse@^7.25.9": + version "7.26.9" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.26.9.tgz#4398f2394ba66d05d988b2ad13c219a2c857461a" + integrity sha512-ZYW7L+pL8ahU5fXmNbPF+iZFHCv5scFak7MZ9bwaRPLUhHh7QQEMjZUg0HevihoqCM5iSYHN61EyCoZvqC+bxg== + dependencies: + "@babel/code-frame" "^7.26.2" + "@babel/generator" "^7.26.9" + "@babel/parser" "^7.26.9" + "@babel/template" "^7.26.9" + "@babel/types" "^7.26.9" + debug "^4.3.1" + globals "^11.1.0" + +"@babel/types@^7.24.5", "@babel/types@^7.25.9", "@babel/types@^7.26.9": + version "7.26.9" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.26.9.tgz#08b43dec79ee8e682c2ac631c010bdcac54a21ce" + integrity sha512-Y3IR1cRnOxOCDvMmNiym7XpXQ93iGDDPHx+Zj+NM+rg0fBaShfQLkg+hKPaZCEvg5N/LeCo4+Rj/i3FuJsIQaw== + dependencies: + "@babel/helper-string-parser" "^7.25.9" + "@babel/helper-validator-identifier" "^7.25.9" + +"@emnapi/runtime@^1.2.0": + version "1.3.1" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.3.1.tgz#0fcaa575afc31f455fd33534c19381cfce6c6f60" + integrity sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw== + dependencies: + tslib "^2.4.0" + +"@esbuild/aix-ppc64@0.23.0": + version "0.23.0" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.23.0.tgz#145b74d5e4a5223489cabdc238d8dad902df5259" + integrity sha512-3sG8Zwa5fMcA9bgqB8AfWPQ+HFke6uD3h1s3RIwUNK8EG7a4buxvuFTs3j1IMs2NXAk9F30C/FF4vxRgQCcmoQ== + +"@esbuild/android-arm64@0.23.0": + version "0.23.0" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.23.0.tgz#453bbe079fc8d364d4c5545069e8260228559832" + integrity sha512-EuHFUYkAVfU4qBdyivULuu03FhJO4IJN9PGuABGrFy4vUuzk91P2d+npxHcFdpUnfYKy0PuV+n6bKIpHOB3prQ== + +"@esbuild/android-arm@0.23.0": + version "0.23.0" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.23.0.tgz#26c806853aa4a4f7e683e519cd9d68e201ebcf99" + integrity sha512-+KuOHTKKyIKgEEqKbGTK8W7mPp+hKinbMBeEnNzjJGyFcWsfrXjSTNluJHCY1RqhxFurdD8uNXQDei7qDlR6+g== + +"@esbuild/android-x64@0.23.0": + version "0.23.0" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.23.0.tgz#1e51af9a6ac1f7143769f7ee58df5b274ed202e6" + integrity sha512-WRrmKidLoKDl56LsbBMhzTTBxrsVwTKdNbKDalbEZr0tcsBgCLbEtoNthOW6PX942YiYq8HzEnb4yWQMLQuipQ== + +"@esbuild/darwin-arm64@0.23.0": + version "0.23.0" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.23.0.tgz#d996187a606c9534173ebd78c58098a44dd7ef9e" + integrity sha512-YLntie/IdS31H54Ogdn+v50NuoWF5BDkEUFpiOChVa9UnKpftgwzZRrI4J132ETIi+D8n6xh9IviFV3eXdxfow== + +"@esbuild/darwin-x64@0.23.0": + version "0.23.0" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.23.0.tgz#30c8f28a7ef4e32fe46501434ebe6b0912e9e86c" + integrity sha512-IMQ6eme4AfznElesHUPDZ+teuGwoRmVuuixu7sv92ZkdQcPbsNHzutd+rAfaBKo8YK3IrBEi9SLLKWJdEvJniQ== + +"@esbuild/freebsd-arm64@0.23.0": + version "0.23.0" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.0.tgz#30f4fcec8167c08a6e8af9fc14b66152232e7fb4" + integrity sha512-0muYWCng5vqaxobq6LB3YNtevDFSAZGlgtLoAc81PjUfiFz36n4KMpwhtAd4he8ToSI3TGyuhyx5xmiWNYZFyw== + +"@esbuild/freebsd-x64@0.23.0": + version "0.23.0" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.23.0.tgz#1003a6668fe1f5d4439e6813e5b09a92981bc79d" + integrity sha512-XKDVu8IsD0/q3foBzsXGt/KjD/yTKBCIwOHE1XwiXmrRwrX6Hbnd5Eqn/WvDekddK21tfszBSrE/WMaZh+1buQ== + +"@esbuild/linux-arm64@0.23.0": + version "0.23.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.23.0.tgz#3b9a56abfb1410bb6c9138790f062587df3e6e3a" + integrity sha512-j1t5iG8jE7BhonbsEg5d9qOYcVZv/Rv6tghaXM/Ug9xahM0nX/H2gfu6X6z11QRTMT6+aywOMA8TDkhPo8aCGw== + +"@esbuild/linux-arm@0.23.0": + version "0.23.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.23.0.tgz#237a8548e3da2c48cd79ae339a588f03d1889aad" + integrity sha512-SEELSTEtOFu5LPykzA395Mc+54RMg1EUgXP+iw2SJ72+ooMwVsgfuwXo5Fn0wXNgWZsTVHwY2cg4Vi/bOD88qw== + +"@esbuild/linux-ia32@0.23.0": + version "0.23.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.23.0.tgz#4269cd19cb2de5de03a7ccfc8855dde3d284a238" + integrity sha512-P7O5Tkh2NbgIm2R6x1zGJJsnacDzTFcRWZyTTMgFdVit6E98LTxO+v8LCCLWRvPrjdzXHx9FEOA8oAZPyApWUA== + +"@esbuild/linux-loong64@0.23.0": + version "0.23.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.23.0.tgz#82b568f5658a52580827cc891cb69d2cb4f86280" + integrity sha512-InQwepswq6urikQiIC/kkx412fqUZudBO4SYKu0N+tGhXRWUqAx+Q+341tFV6QdBifpjYgUndV1hhMq3WeJi7A== + +"@esbuild/linux-mips64el@0.23.0": + version "0.23.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.23.0.tgz#9a57386c926262ae9861c929a6023ed9d43f73e5" + integrity sha512-J9rflLtqdYrxHv2FqXE2i1ELgNjT+JFURt/uDMoPQLcjWQA5wDKgQA4t/dTqGa88ZVECKaD0TctwsUfHbVoi4w== + +"@esbuild/linux-ppc64@0.23.0": + version "0.23.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.23.0.tgz#f3a79fd636ba0c82285d227eb20ed8e31b4444f6" + integrity sha512-cShCXtEOVc5GxU0fM+dsFD10qZ5UpcQ8AM22bYj0u/yaAykWnqXJDpd77ublcX6vdDsWLuweeuSNZk4yUxZwtw== + +"@esbuild/linux-riscv64@0.23.0": + version "0.23.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.23.0.tgz#f9d2ef8356ce6ce140f76029680558126b74c780" + integrity sha512-HEtaN7Y5UB4tZPeQmgz/UhzoEyYftbMXrBCUjINGjh3uil+rB/QzzpMshz3cNUxqXN7Vr93zzVtpIDL99t9aRw== + +"@esbuild/linux-s390x@0.23.0": + version "0.23.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.23.0.tgz#45390f12e802201f38a0229e216a6aed4351dfe8" + integrity sha512-WDi3+NVAuyjg/Wxi+o5KPqRbZY0QhI9TjrEEm+8dmpY9Xir8+HE/HNx2JoLckhKbFopW0RdO2D72w8trZOV+Wg== + +"@esbuild/linux-x64@0.23.0": + version "0.23.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.23.0.tgz#c8409761996e3f6db29abcf9b05bee8d7d80e910" + integrity sha512-a3pMQhUEJkITgAw6e0bWA+F+vFtCciMjW/LPtoj99MhVt+Mfb6bbL9hu2wmTZgNd994qTAEw+U/r6k3qHWWaOQ== + +"@esbuild/netbsd-x64@0.23.0": + version "0.23.0" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.23.0.tgz#ba70db0114380d5f6cfb9003f1d378ce989cd65c" + integrity sha512-cRK+YDem7lFTs2Q5nEv/HHc4LnrfBCbH5+JHu6wm2eP+d8OZNoSMYgPZJq78vqQ9g+9+nMuIsAO7skzphRXHyw== + +"@esbuild/openbsd-arm64@0.23.0": + version "0.23.0" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.0.tgz#72fc55f0b189f7a882e3cf23f332370d69dfd5db" + integrity sha512-suXjq53gERueVWu0OKxzWqk7NxiUWSUlrxoZK7usiF50C6ipColGR5qie2496iKGYNLhDZkPxBI3erbnYkU0rQ== + +"@esbuild/openbsd-x64@0.23.0": + version "0.23.0" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.23.0.tgz#b6ae7a0911c18fe30da3db1d6d17a497a550e5d8" + integrity sha512-6p3nHpby0DM/v15IFKMjAaayFhqnXV52aEmv1whZHX56pdkK+MEaLoQWj+H42ssFarP1PcomVhbsR4pkz09qBg== + +"@esbuild/sunos-x64@0.23.0": + version "0.23.0" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.23.0.tgz#58f0d5e55b9b21a086bfafaa29f62a3eb3470ad8" + integrity sha512-BFelBGfrBwk6LVrmFzCq1u1dZbG4zy/Kp93w2+y83Q5UGYF1d8sCzeLI9NXjKyujjBBniQa8R8PzLFAUrSM9OA== + +"@esbuild/win32-arm64@0.23.0": + version "0.23.0" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.23.0.tgz#b858b2432edfad62e945d5c7c9e5ddd0f528ca6d" + integrity sha512-lY6AC8p4Cnb7xYHuIxQ6iYPe6MfO2CC43XXKo9nBXDb35krYt7KGhQnOkRGar5psxYkircpCqfbNDB4uJbS2jQ== + +"@esbuild/win32-ia32@0.23.0": + version "0.23.0" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.23.0.tgz#167ef6ca22a476c6c0c014a58b4f43ae4b80dec7" + integrity sha512-7L1bHlOTcO4ByvI7OXVI5pNN6HSu6pUQq9yodga8izeuB1KcT2UkHaH6118QJwopExPn0rMHIseCTx1CRo/uNA== + +"@esbuild/win32-x64@0.23.0": + version "0.23.0" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.23.0.tgz#db44a6a08520b5f25bbe409f34a59f2d4bcc7ced" + integrity sha512-Arm+WgUFLUATuoxCJcahGuk6Yj9Pzxd6l11Zb/2aAuv5kWWvvfhLFo2fni4uSK5vzlUdCGZ/BdV5tH8klj8p8g== + +"@img/sharp-darwin-arm64@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz#ef5b5a07862805f1e8145a377c8ba6e98813ca08" + integrity sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ== + optionalDependencies: + "@img/sharp-libvips-darwin-arm64" "1.0.4" + +"@img/sharp-darwin-x64@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz#e03d3451cd9e664faa72948cc70a403ea4063d61" + integrity sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q== + optionalDependencies: + "@img/sharp-libvips-darwin-x64" "1.0.4" + +"@img/sharp-libvips-darwin-arm64@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz#447c5026700c01a993c7804eb8af5f6e9868c07f" + integrity sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg== + +"@img/sharp-libvips-darwin-x64@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz#e0456f8f7c623f9dbfbdc77383caa72281d86062" + integrity sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ== + +"@img/sharp-libvips-linux-arm64@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz#979b1c66c9a91f7ff2893556ef267f90ebe51704" + integrity sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA== + +"@img/sharp-libvips-linux-arm@1.0.5": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz#99f922d4e15216ec205dcb6891b721bfd2884197" + integrity sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g== + +"@img/sharp-libvips-linux-s390x@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz#f8a5eb1f374a082f72b3f45e2fb25b8118a8a5ce" + integrity sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA== + +"@img/sharp-libvips-linux-x64@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz#d4c4619cdd157774906e15770ee119931c7ef5e0" + integrity sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw== + +"@img/sharp-libvips-linuxmusl-arm64@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz#166778da0f48dd2bded1fa3033cee6b588f0d5d5" + integrity sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA== + +"@img/sharp-libvips-linuxmusl-x64@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz#93794e4d7720b077fcad3e02982f2f1c246751ff" + integrity sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw== + +"@img/sharp-linux-arm64@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz#edb0697e7a8279c9fc829a60fc35644c4839bb22" + integrity sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA== + optionalDependencies: + "@img/sharp-libvips-linux-arm64" "1.0.4" + +"@img/sharp-linux-arm@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz#422c1a352e7b5832842577dc51602bcd5b6f5eff" + integrity sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ== + optionalDependencies: + "@img/sharp-libvips-linux-arm" "1.0.5" + +"@img/sharp-linux-s390x@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz#f5c077926b48e97e4a04d004dfaf175972059667" + integrity sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q== + optionalDependencies: + "@img/sharp-libvips-linux-s390x" "1.0.4" + +"@img/sharp-linux-x64@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz#d806e0afd71ae6775cc87f0da8f2d03a7c2209cb" + integrity sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA== + optionalDependencies: + "@img/sharp-libvips-linux-x64" "1.0.4" + +"@img/sharp-linuxmusl-arm64@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz#252975b915894fb315af5deea174651e208d3d6b" + integrity sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g== + optionalDependencies: + "@img/sharp-libvips-linuxmusl-arm64" "1.0.4" + +"@img/sharp-linuxmusl-x64@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz#3f4609ac5d8ef8ec7dadee80b560961a60fd4f48" + integrity sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw== + optionalDependencies: + "@img/sharp-libvips-linuxmusl-x64" "1.0.4" + +"@img/sharp-wasm32@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz#6f44f3283069d935bb5ca5813153572f3e6f61a1" + integrity sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg== + dependencies: + "@emnapi/runtime" "^1.2.0" + +"@img/sharp-win32-ia32@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz#1a0c839a40c5351e9885628c85f2e5dfd02b52a9" + integrity sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ== + +"@img/sharp-win32-x64@0.33.5": + version "0.33.5" + resolved "https://registry.yarnpkg.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz#56f00962ff0c4e0eb93d34a047d29fa995e3e342" + integrity sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg== + +"@isaacs/cliui@^8.0.2": + version "8.0.2" + resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" + integrity sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA== + dependencies: + string-width "^5.1.2" + string-width-cjs "npm:string-width@^4.2.0" + strip-ansi "^7.0.1" + strip-ansi-cjs "npm:strip-ansi@^6.0.1" + wrap-ansi "^8.1.0" + wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" + +"@jridgewell/gen-mapping@^0.3.5": + version "0.3.8" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz#4f0e06362e01362f823d348f1872b08f666d8142" + integrity sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA== + dependencies: + "@jridgewell/set-array" "^1.2.1" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/resolve-uri@^3.1.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + +"@jridgewell/set-array@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.2.1.tgz#558fb6472ed16a4c850b889530e6b36438c49280" + integrity sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A== + +"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz#3188bcb273a414b0d215fd22a58540b989b9409a" + integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== + +"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": + version "0.3.25" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" + integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + +"@next/env@15.1.2": + version "15.1.2" + resolved "https://registry.yarnpkg.com/@next/env/-/env-15.1.2.tgz#fa36e47bbaa33b9ecac228aa786bb05bbc15351c" + integrity sha512-Hm3jIGsoUl6RLB1vzY+dZeqb+/kWPZ+h34yiWxW0dV87l8Im/eMOwpOA+a0L78U0HM04syEjXuRlCozqpwuojQ== + +"@next/swc-darwin-arm64@15.1.2": + version "15.1.2" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.1.2.tgz#822265999fc76f828f4c671a5ef861b8e2c5213e" + integrity sha512-b9TN7q+j5/7+rGLhFAVZiKJGIASuo8tWvInGfAd8wsULjB1uNGRCj1z1WZwwPWzVQbIKWFYqc+9L7W09qwt52w== + +"@next/swc-darwin-x64@15.1.2": + version "15.1.2" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-15.1.2.tgz#78d277bce3d35c6e8d9ad423b6f5b0031aa9a1e2" + integrity sha512-caR62jNDUCU+qobStO6YJ05p9E+LR0EoXh1EEmyU69cYydsAy7drMcOlUlRtQihM6K6QfvNwJuLhsHcCzNpqtA== + +"@next/swc-linux-arm64-gnu@15.1.2": + version "15.1.2" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.1.2.tgz#4d48c8c37da869b0fdbb51f3f3f71df7a3b6b1bb" + integrity sha512-fHHXBusURjBmN6VBUtu6/5s7cCeEkuGAb/ZZiGHBLVBXMBy4D5QpM8P33Or8JD1nlOjm/ZT9sEE5HouQ0F+hUA== + +"@next/swc-linux-arm64-musl@15.1.2": + version "15.1.2" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.1.2.tgz#0efbaffc2bc3fad4a6458c91b1655b0c3d509577" + integrity sha512-9CF1Pnivij7+M3G74lxr+e9h6o2YNIe7QtExWq1KUK4hsOLTBv6FJikEwCaC3NeYTflzrm69E5UfwEAbV2U9/g== + +"@next/swc-linux-x64-gnu@15.1.2": + version "15.1.2" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.1.2.tgz#fcdb19e2a7602f85f103190539d0cf42eca7f217" + integrity sha512-tINV7WmcTUf4oM/eN3Yuu/f8jQ5C6AkueZPKeALs/qfdfX57eNv4Ij7rt0SA6iZ8+fMobVfcFVv664Op0caCCg== + +"@next/swc-linux-x64-musl@15.1.2": + version "15.1.2" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.1.2.tgz#06b09f1712498dd5c61fac10c56a09535469b4c4" + integrity sha512-jf2IseC4WRsGkzeUw/cK3wci9pxR53GlLAt30+y+B+2qAQxMw6WAC3QrANIKxkcoPU3JFh/10uFfmoMDF9JXKg== + +"@next/swc-win32-arm64-msvc@15.1.2": + version "15.1.2" + resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.1.2.tgz#63159223241ff45e8df76b24fc979bbb933c74df" + integrity sha512-wvg7MlfnaociP7k8lxLX4s2iBJm4BrNiNFhVUY+Yur5yhAJHfkS8qPPeDEUH8rQiY0PX3u/P7Q/wcg6Mv6GSAA== + +"@next/swc-win32-x64-msvc@15.1.2": + version "15.1.2" + resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.1.2.tgz#6e6b33b1d725c0e98fa76773fe437fb02ad6540b" + integrity sha512-D3cNA8NoT3aWISWmo7HF5Eyko/0OdOO+VagkoJuiTk7pyX3P/b+n8XA/MYvyR+xSVcbKn68B1rY9fgqjNISqzQ== + +"@one-ini/wasm@0.1.1": + version "0.1.1" + resolved "https://registry.yarnpkg.com/@one-ini/wasm/-/wasm-0.1.1.tgz#6013659736c9dbfccc96e8a9c2b3de317df39323" + integrity sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw== + +"@pkgjs/parseargs@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" + integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== + +"@react-email/body@0.0.11": + version "0.0.11" + resolved "https://registry.yarnpkg.com/@react-email/body/-/body-0.0.11.tgz#996bc8ab0038a3b183d086fc4b5c581764b36d32" + integrity sha512-ZSD2SxVSgUjHGrB0Wi+4tu3MEpB4fYSbezsFNEJk2xCWDBkFiOeEsjTmR5dvi+CxTK691hQTQlHv0XWuP7ENTg== + +"@react-email/button@0.0.19": + version "0.0.19" + resolved "https://registry.yarnpkg.com/@react-email/button/-/button-0.0.19.tgz#f7585fd560f59be661c6ccdc3211df8b17f9fc86" + integrity sha512-HYHrhyVGt7rdM/ls6FuuD6XE7fa7bjZTJqB2byn6/oGsfiEZaogY77OtoLL/mrQHjHjZiJadtAMSik9XLcm7+A== + +"@react-email/code-block@0.0.11": + version "0.0.11" + resolved "https://registry.yarnpkg.com/@react-email/code-block/-/code-block-0.0.11.tgz#20369e4ac25789345f13d3c97c92a37536e81201" + integrity sha512-4D43p+LIMjDzm66gTDrZch0Flkip5je91mAT7iGs6+SbPyalHgIA+lFQoQwhz/VzHHLxuD0LV6gwmU/WUQ2WEg== + dependencies: + prismjs "1.29.0" + +"@react-email/code-inline@0.0.5": + version "0.0.5" + resolved "https://registry.yarnpkg.com/@react-email/code-inline/-/code-inline-0.0.5.tgz#1ca46f4d44207c3dcca3ce35210f643d811ee8e0" + integrity sha512-MmAsOzdJpzsnY2cZoPHFPk6uDO/Ncpb4Kh1hAt9UZc1xOW3fIzpe1Pi9y9p6wwUmpaeeDalJxAxH6/fnTquinA== + +"@react-email/column@0.0.13": + version "0.0.13" + resolved "https://registry.yarnpkg.com/@react-email/column/-/column-0.0.13.tgz#0aaa5d6abae1b590a0262bfbfd241344a2c60ad3" + integrity sha512-Lqq17l7ShzJG/d3b1w/+lVO+gp2FM05ZUo/nW0rjxB8xBICXOVv6PqjDnn3FXKssvhO5qAV20lHM6S+spRhEwQ== + +"@react-email/components@0.0.33": + version "0.0.33" + resolved "https://registry.yarnpkg.com/@react-email/components/-/components-0.0.33.tgz#ac641aa897c60bf38d559275e3b29e9dfa7867ce" + integrity sha512-/GKdT3YijT1iEWPAXF644jr12w5xVgzUr0zlbZGt2KOkGeFHNZUCL5UtRopmnjrH/Fayf8Gjv6q/4E2cZgDtdQ== + dependencies: + "@react-email/body" "0.0.11" + "@react-email/button" "0.0.19" + "@react-email/code-block" "0.0.11" + "@react-email/code-inline" "0.0.5" + "@react-email/column" "0.0.13" + "@react-email/container" "0.0.15" + "@react-email/font" "0.0.9" + "@react-email/head" "0.0.12" + "@react-email/heading" "0.0.15" + "@react-email/hr" "0.0.11" + "@react-email/html" "0.0.11" + "@react-email/img" "0.0.11" + "@react-email/link" "0.0.12" + "@react-email/markdown" "0.0.14" + "@react-email/preview" "0.0.12" + "@react-email/render" "1.0.5" + "@react-email/row" "0.0.12" + "@react-email/section" "0.0.16" + "@react-email/tailwind" "1.0.4" + "@react-email/text" "0.0.11" + +"@react-email/container@0.0.15": + version "0.0.15" + resolved "https://registry.yarnpkg.com/@react-email/container/-/container-0.0.15.tgz#6a35f0792cfd4b1ec557c7d566cdc2ecbdeb6d01" + integrity sha512-Qo2IQo0ru2kZq47REmHW3iXjAQaKu4tpeq/M8m1zHIVwKduL2vYOBQWbC2oDnMtWPmkBjej6XxgtZByxM6cCFg== + +"@react-email/font@0.0.9": + version "0.0.9" + resolved "https://registry.yarnpkg.com/@react-email/font/-/font-0.0.9.tgz#fe2ae24c408fe605cb564f75ef00d826a3dbdd35" + integrity sha512-4zjq23oT9APXkerqeslPH3OZWuh5X4crHK6nx82mVHV2SrLba8+8dPEnWbaACWTNjOCbcLIzaC9unk7Wq2MIXw== + +"@react-email/head@0.0.12": + version "0.0.12" + resolved "https://registry.yarnpkg.com/@react-email/head/-/head-0.0.12.tgz#83c1beba4e0e5207aa6f7ae9601f8cbdbda23d13" + integrity sha512-X2Ii6dDFMF+D4niNwMAHbTkeCjlYYnMsd7edXOsi0JByxt9wNyZ9EnhFiBoQdqkE+SMDcu8TlNNttMrf5sJeMA== + +"@react-email/heading@0.0.15": + version "0.0.15" + resolved "https://registry.yarnpkg.com/@react-email/heading/-/heading-0.0.15.tgz#c6dd807ae684cbc37402fdd23dbd5500e245b531" + integrity sha512-xF2GqsvBrp/HbRHWEfOgSfRFX+Q8I5KBEIG5+Lv3Vb2R/NYr0s8A5JhHHGf2pWBMJdbP4B2WHgj/VUrhy8dkIg== + +"@react-email/hr@0.0.11": + version "0.0.11" + resolved "https://registry.yarnpkg.com/@react-email/hr/-/hr-0.0.11.tgz#bce7748f5c1b9558429e733e5a239b09d06b77db" + integrity sha512-S1gZHVhwOsd1Iad5IFhpfICwNPMGPJidG/Uysy1AwmspyoAP5a4Iw3OWEpINFdgh9MHladbxcLKO2AJO+cA9Lw== + +"@react-email/html@0.0.11": + version "0.0.11" + resolved "https://registry.yarnpkg.com/@react-email/html/-/html-0.0.11.tgz#10557538436cb098819b8c7a0d1da9ddd8aaf4d8" + integrity sha512-qJhbOQy5VW5qzU74AimjAR9FRFQfrMa7dn4gkEXKMB/S9xZN8e1yC1uA9C15jkXI/PzmJ0muDIWmFwatm5/+VA== + +"@react-email/img@0.0.11": + version "0.0.11" + resolved "https://registry.yarnpkg.com/@react-email/img/-/img-0.0.11.tgz#ee2c9d6b2c61e8e9537a00b6d5f3d51428410f85" + integrity sha512-aGc8Y6U5C3igoMaqAJKsCpkbm1XjguQ09Acd+YcTKwjnC2+0w3yGUJkjWB2vTx4tN8dCqQCXO8FmdJpMfOA9EQ== + +"@react-email/link@0.0.12": + version "0.0.12" + resolved "https://registry.yarnpkg.com/@react-email/link/-/link-0.0.12.tgz#3b65e50af546e2539d9bfbb8442c7bdad7007235" + integrity sha512-vF+xxQk2fGS1CN7UPQDbzvcBGfffr+GjTPNiWM38fhBfsLv6A/YUfaqxWlmL7zLzVmo0K2cvvV9wxlSyNba1aQ== + +"@react-email/markdown@0.0.14": + version "0.0.14" + resolved "https://registry.yarnpkg.com/@react-email/markdown/-/markdown-0.0.14.tgz#12ec4e48f2bdeea99065ad40708fd04529de510d" + integrity sha512-5IsobCyPkb4XwnQO8uFfGcNOxnsg3311GRXhJ3uKv51P7Jxme4ycC/MITnwIZ10w2zx7HIyTiqVzTj4XbuIHbg== + dependencies: + md-to-react-email "5.0.5" + +"@react-email/preview@0.0.12": + version "0.0.12" + resolved "https://registry.yarnpkg.com/@react-email/preview/-/preview-0.0.12.tgz#5c1291d7e18d63991430e59da43dd9952d2d9988" + integrity sha512-g/H5fa9PQPDK6WUEG7iTlC19sAktI23qyoiJtMLqQiXFCfWeQMhqjLGKeLSKkfzszqmfJCjZtpSiKtBoOdxp3Q== + +"@react-email/render@1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@react-email/render/-/render-1.0.1.tgz#5a8897a2b87c1aa41ebe5dd36233bd8d983b801a" + integrity sha512-W3gTrcmLOVYnG80QuUp22ReIT/xfLsVJ+n7ghSlG2BITB8evNABn1AO2rGQoXuK84zKtDAlxCdm3hRyIpZdGSA== + dependencies: + html-to-text "9.0.5" + js-beautify "^1.14.11" + react-promise-suspense "0.3.4" + +"@react-email/render@1.0.5": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@react-email/render/-/render-1.0.5.tgz#88285b9314b85d2bc102f5bf3e558be82b87bc18" + integrity sha512-CA69HYXPk21HhtAXATIr+9JJwpDNmAFCvdMUjWmeoD1+KhJ9NAxusMRxKNeibdZdslmq3edaeOKGbdQ9qjK8LQ== + dependencies: + html-to-text "9.0.5" + prettier "3.4.2" + react-promise-suspense "0.3.4" + +"@react-email/row@0.0.12": + version "0.0.12" + resolved "https://registry.yarnpkg.com/@react-email/row/-/row-0.0.12.tgz#c9d18f6d4b173e95787949909a48ad5a5633bbf2" + integrity sha512-HkCdnEjvK3o+n0y0tZKXYhIXUNPDx+2vq1dJTmqappVHXS5tXS6W5JOPZr5j+eoZ8gY3PShI2LWj5rWF7ZEtIQ== + +"@react-email/section@0.0.16": + version "0.0.16" + resolved "https://registry.yarnpkg.com/@react-email/section/-/section-0.0.16.tgz#34214e6367d8c1b725b38a8b89391cd38f076a81" + integrity sha512-FjqF9xQ8FoeUZYKSdt8sMIKvoT9XF8BrzhT3xiFKdEMwYNbsDflcjfErJe3jb7Wj/es/lKTbV5QR1dnLzGpL3w== + +"@react-email/tailwind@1.0.4": + version "1.0.4" + resolved "https://registry.yarnpkg.com/@react-email/tailwind/-/tailwind-1.0.4.tgz#e2685cc16c87f63c30bb0b766fd494c0fc15b2da" + integrity sha512-tJdcusncdqgvTUYZIuhNC6LYTfL9vNTSQpwWdTCQhQ1lsrNCEE4OKCSdzSV3S9F32pi0i0xQ+YPJHKIzGjdTSA== + +"@react-email/text@0.0.11": + version "0.0.11" + resolved "https://registry.yarnpkg.com/@react-email/text/-/text-0.0.11.tgz#b0c24a9eba5f841fe8c4d303dc6f5c1a1095b5e3" + integrity sha512-a7nl/2KLpRHOYx75YbYZpWspUbX1DFY7JIZbOv5x0QU8SvwDbJt+Hm01vG34PffFyYvHEXrc6Qnip2RTjljNjg== + +"@selderee/plugin-htmlparser2@^0.11.0": + version "0.11.0" + resolved "https://registry.yarnpkg.com/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz#d5b5e29a7ba6d3958a1972c7be16f4b2c188c517" + integrity sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ== + dependencies: + domhandler "^5.0.3" + selderee "^0.11.0" + +"@socket.io/component-emitter@~3.1.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz#821f8442f4175d8f0467b9daf26e3a18e2d02af2" + integrity sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA== + +"@swc/counter@0.1.3": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@swc/counter/-/counter-0.1.3.tgz#cc7463bd02949611c6329596fccd2b0ec782b0e9" + integrity sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ== + +"@swc/helpers@0.5.15": + version "0.5.15" + resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.15.tgz#79efab344c5819ecf83a43f3f9f811fc84b516d7" + integrity sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g== + dependencies: + tslib "^2.8.0" + +"@types/cors@^2.8.12": + version "2.8.17" + resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.17.tgz#5d718a5e494a8166f569d986794e49c48b216b2b" + integrity sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA== + dependencies: + "@types/node" "*" + +"@types/html-to-text@9.0.4": + version "9.0.4" + resolved "https://registry.yarnpkg.com/@types/html-to-text/-/html-to-text-9.0.4.tgz#4a83dd8ae8bfa91457d0b1ffc26f4d0537eff58c" + integrity sha512-pUY3cKH/Nm2yYrEmDlPR1mR7yszjGx4DrwPjQ702C4/D5CwHuZTgZdIdwPkRbcuhs7BAh2L5rg3CL5cbRiGTCQ== + +"@types/node@*", "@types/node@>=10.0.0": + version "22.13.9" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.13.9.tgz#5d9a8f7a975a5bd3ef267352deb96fb13ec02eca" + integrity sha512-acBjXdRJ3A6Pb3tqnw9HZmyR3Fiol3aGxRCK1x3d+6CDAMjl7I649wpSd+yNURCjbOUGu9tqtLKnTGxmK6CyGw== + dependencies: + undici-types "~6.20.0" + +"@types/prismjs@1.26.5": + version "1.26.5" + resolved "https://registry.yarnpkg.com/@types/prismjs/-/prismjs-1.26.5.tgz#72499abbb4c4ec9982446509d2f14fb8483869d6" + integrity sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ== + +"@types/react-dom@19.0.1": + version "19.0.1" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.0.1.tgz#b1032c4c3215018e4028a85a71441560216e51c6" + integrity sha512-hljHij7MpWPKF6u5vojuyfV0YA4YURsQG7KT6SzV0Zs2BXAtgdTxG6A229Ub/xiWV4w/7JL8fi6aAyjshH4meA== + dependencies: + "@types/react" "*" + +"@types/react@*": + version "19.0.10" + resolved "https://registry.yarnpkg.com/@types/react/-/react-19.0.10.tgz#d0c66dafd862474190fe95ce11a68de69ed2b0eb" + integrity sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g== + dependencies: + csstype "^3.0.2" + +"@types/react@19.0.1": + version "19.0.1" + resolved "https://registry.yarnpkg.com/@types/react/-/react-19.0.1.tgz#a000d5b78f473732a08cecbead0f3751e550b3df" + integrity sha512-YW6614BDhqbpR5KtUYzTA+zlA7nayzJRA9ljz9CQoxthR0sDisYZLuvSMsil36t4EH/uAt8T52Xb4sVw17G+SQ== + dependencies: + csstype "^3.0.2" + +abbrev@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-2.0.0.tgz#cf59829b8b4f03f89dda2771cb7f3653828c89bf" + integrity sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ== + +accepts@~1.3.4: + version "1.3.8" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" + integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== + dependencies: + mime-types "~2.1.34" + negotiator "0.6.3" + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-regex@^6.0.1: + version "6.1.0" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.1.0.tgz#95ec409c69619d6cb1b8b34f14b660ef28ebd654" + integrity sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA== + +ansi-styles@^4.0.0, ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +ansi-styles@^6.1.0: + version "6.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5" + integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug== + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +base64-js@^1.3.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" + integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== + +base64id@2.0.0, base64id@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/base64id/-/base64id-2.0.0.tgz#2770ac6bc47d312af97a8bf9a634342e0cd25cb6" + integrity sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog== + +bl@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" + integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w== + dependencies: + buffer "^5.5.0" + inherits "^2.0.4" + readable-stream "^3.4.0" + +brace-expansion@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" + integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== + dependencies: + balanced-match "^1.0.0" + +browserslist@^4.24.0: + version "4.24.4" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.24.4.tgz#c6b2865a3f08bcb860a0e827389003b9fe686e4b" + integrity sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A== + dependencies: + caniuse-lite "^1.0.30001688" + electron-to-chromium "^1.5.73" + node-releases "^2.0.19" + update-browserslist-db "^1.1.1" + +buffer@^5.5.0: + version "5.7.1" + resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" + integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== + dependencies: + base64-js "^1.3.1" + ieee754 "^1.1.13" + +busboy@1.6.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" + integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA== + dependencies: + streamsearch "^1.1.0" + +caniuse-lite@^1.0.30001579, caniuse-lite@^1.0.30001688: + version "1.0.30001702" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001702.tgz#cde16fa8adaa066c04aec2967b6cde46354644c4" + integrity sha512-LoPe/D7zioC0REI5W73PeR1e1MLCipRGq/VkovJnd6Df+QVqT+vT33OXCp8QUd7kA7RZrHWxb1B36OQKI/0gOA== + +chalk@4.1.2, chalk@^4.1.0: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +chokidar@4.0.3: + version "4.0.3" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30" + integrity sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA== + dependencies: + readdirp "^4.0.1" + +cli-cursor@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307" + integrity sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw== + dependencies: + restore-cursor "^3.1.0" + +cli-spinners@^2.5.0: + version "2.9.2" + resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.9.2.tgz#1773a8f4b9c4d6ac31563df53b3fc1d79462fe41" + integrity sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg== + +client-only@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1" + integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA== + +clone@^1.0.2: + version "1.0.4" + resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" + integrity sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg== + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@^1.0.0, color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +color-string@^1.9.0: + version "1.9.1" + resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.9.1.tgz#4467f9146f036f855b764dfb5bf8582bf342c7a4" + integrity sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg== + dependencies: + color-name "^1.0.0" + simple-swizzle "^0.2.2" + +color@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/color/-/color-4.2.3.tgz#d781ecb5e57224ee43ea9627560107c0e0c6463a" + integrity sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A== + dependencies: + color-convert "^2.0.1" + color-string "^1.9.0" + +commander@11.1.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-11.1.0.tgz#62fdce76006a68e5c1ab3314dc92e800eb83d906" + integrity sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ== + +commander@^10.0.0: + version "10.0.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06" + integrity sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug== + +config-chain@^1.1.13: + version "1.1.13" + resolved "https://registry.yarnpkg.com/config-chain/-/config-chain-1.1.13.tgz#fad0795aa6a6cdaff9ed1b68e9dff94372c232f4" + integrity sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ== + dependencies: + ini "^1.3.4" + proto-list "~1.2.1" + +convert-source-map@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" + integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== + +cookie@~0.7.2: + version "0.7.2" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" + integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== + +cors@~2.8.5: + version "2.8.5" + resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" + integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== + dependencies: + object-assign "^4" + vary "^1" + +cross-spawn@^7.0.6: + version "7.0.6" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" + integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +csstype@^3.0.2: + version "3.1.3" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" + integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== + +debounce@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/debounce/-/debounce-2.0.0.tgz#b2f914518a1481466f4edaee0b063e4d473ad549" + integrity sha512-xRetU6gL1VJbs85Mc4FoEGSjQxzpdxRyFhe3lmWFyy2EzydIcD4xzUvRJMD+NPDfMwKNhxa3PvsIOU32luIWeA== + +debug@^4.1.0, debug@^4.3.1: + version "4.4.0" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.0.tgz#2b3f2aea2ffeb776477460267377dc8710faba8a" + integrity sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA== + dependencies: + ms "^2.1.3" + +debug@~4.3.1, debug@~4.3.2, debug@~4.3.4: + version "4.3.7" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" + integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== + dependencies: + ms "^2.1.3" + +deepmerge@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" + integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== + +defaults@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/defaults/-/defaults-1.0.4.tgz#b0b02062c1e2aa62ff5d9528f0f98baa90978d7a" + integrity sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A== + dependencies: + clone "^1.0.2" + +detect-libc@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.3.tgz#f0cd503b40f9939b894697d19ad50895e30cf700" + integrity sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw== + +dom-serializer@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53" + integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.2" + entities "^4.2.0" + +domelementtype@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" + integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== + +domhandler@^5.0.2, domhandler@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31" + integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w== + dependencies: + domelementtype "^2.3.0" + +domutils@^3.0.1: + version "3.2.2" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.2.2.tgz#edbfe2b668b0c1d97c24baf0f1062b132221bc78" + integrity sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw== + dependencies: + dom-serializer "^2.0.0" + domelementtype "^2.3.0" + domhandler "^5.0.3" + +eastasianwidth@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" + integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== + +editorconfig@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/editorconfig/-/editorconfig-1.0.4.tgz#040c9a8e9a6c5288388b87c2db07028aa89f53a3" + integrity sha512-L9Qe08KWTlqYMVvMcTIvMAdl1cDUubzRNYL+WfA4bLDMHe4nemKkpmYzkznE1FwLKu0EEmy6obgQKzMJrg4x9Q== + dependencies: + "@one-ini/wasm" "0.1.1" + commander "^10.0.0" + minimatch "9.0.1" + semver "^7.5.3" + +electron-to-chromium@^1.5.73: + version "1.5.113" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.113.tgz#1175b8ba4170541e44e9afa8b992e5bbfff0d150" + integrity sha512-wjT2O4hX+wdWPJ76gWSkMhcHAV2PTMX+QetUCPYEdCIe+cxmgzzSSiGRCKW8nuh4mwKZlpv0xvoW7OF2X+wmHg== + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +emoji-regex@^9.2.2: + version "9.2.2" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-9.2.2.tgz#840c8803b0d8047f4ff0cf963176b32d4ef3ed72" + integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== + +engine.io-parser@~5.2.1: + version "5.2.3" + resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.2.3.tgz#00dc5b97b1f233a23c9398d0209504cf5f94d92f" + integrity sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q== + +engine.io@~6.6.0: + version "6.6.4" + resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-6.6.4.tgz#0a89a3e6b6c1d4b0c2a2a637495e7c149ec8d8ee" + integrity sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g== + dependencies: + "@types/cors" "^2.8.12" + "@types/node" ">=10.0.0" + accepts "~1.3.4" + base64id "2.0.0" + cookie "~0.7.2" + cors "~2.8.5" + debug "~4.3.1" + engine.io-parser "~5.2.1" + ws "~8.17.1" + +entities@^4.2.0, entities@^4.4.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" + integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + +esbuild@0.23.0: + version "0.23.0" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.23.0.tgz#de06002d48424d9fdb7eb52dbe8e95927f852599" + integrity sha512-1lvV17H2bMYda/WaFb2jLPeHU3zml2k4/yagNMG8Q/YtfMjCwEUZa2eXXMgZTVSL5q1n4H7sQ0X6CdJDqqeCFA== + optionalDependencies: + "@esbuild/aix-ppc64" "0.23.0" + "@esbuild/android-arm" "0.23.0" + "@esbuild/android-arm64" "0.23.0" + "@esbuild/android-x64" "0.23.0" + "@esbuild/darwin-arm64" "0.23.0" + "@esbuild/darwin-x64" "0.23.0" + "@esbuild/freebsd-arm64" "0.23.0" + "@esbuild/freebsd-x64" "0.23.0" + "@esbuild/linux-arm" "0.23.0" + "@esbuild/linux-arm64" "0.23.0" + "@esbuild/linux-ia32" "0.23.0" + "@esbuild/linux-loong64" "0.23.0" + "@esbuild/linux-mips64el" "0.23.0" + "@esbuild/linux-ppc64" "0.23.0" + "@esbuild/linux-riscv64" "0.23.0" + "@esbuild/linux-s390x" "0.23.0" + "@esbuild/linux-x64" "0.23.0" + "@esbuild/netbsd-x64" "0.23.0" + "@esbuild/openbsd-arm64" "0.23.0" + "@esbuild/openbsd-x64" "0.23.0" + "@esbuild/sunos-x64" "0.23.0" + "@esbuild/win32-arm64" "0.23.0" + "@esbuild/win32-ia32" "0.23.0" + "@esbuild/win32-x64" "0.23.0" + +escalade@^3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" + integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== + +fast-deep-equal@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49" + integrity sha512-bCK/2Z4zLidyB4ReuIsvALH6w31YfAQDmXMqMx6FyfHqvBxtjC0eRumeSu4Bs3XtXwpyIywtSTrVT99BxY1f9w== + +foreground-child@^3.1.0: + version "3.3.1" + resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" + integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== + dependencies: + cross-spawn "^7.0.6" + signal-exit "^4.0.1" + +gensync@^1.0.0-beta.2: + version "1.0.0-beta.2" + resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" + integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== + +glob@10.3.4: + version "10.3.4" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.3.4.tgz#c85c9c7ab98669102b6defda76d35c5b1ef9766f" + integrity sha512-6LFElP3A+i/Q8XQKEvZjkEWEOTgAIALR9AO2rwT8bgPhDd1anmqDJDZ6lLddI4ehxxxR1S5RIqKe1uapMQfYaQ== + dependencies: + foreground-child "^3.1.0" + jackspeak "^2.0.3" + minimatch "^9.0.1" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" + path-scurry "^1.10.1" + +glob@^10.4.2: + version "10.4.5" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" + integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg== + dependencies: + foreground-child "^3.1.0" + jackspeak "^3.1.2" + minimatch "^9.0.4" + minipass "^7.1.2" + package-json-from-dist "^1.0.0" + path-scurry "^1.11.1" + +globals@^11.1.0: + version "11.12.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" + integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +html-to-text@9.0.5: + version "9.0.5" + resolved "https://registry.yarnpkg.com/html-to-text/-/html-to-text-9.0.5.tgz#6149a0f618ae7a0db8085dca9bbf96d32bb8368d" + integrity sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg== + dependencies: + "@selderee/plugin-htmlparser2" "^0.11.0" + deepmerge "^4.3.1" + dom-serializer "^2.0.0" + htmlparser2 "^8.0.2" + selderee "^0.11.0" + +htmlparser2@^8.0.2: + version "8.0.2" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-8.0.2.tgz#f002151705b383e62433b5cf466f5b716edaec21" + integrity sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.3" + domutils "^3.0.1" + entities "^4.4.0" + +ieee754@^1.1.13: + version "1.2.1" + resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" + integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== + +inherits@^2.0.3, inherits@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +ini@^1.3.4: + version "1.3.8" + resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" + integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== + +is-arrayish@^0.3.1: + version "0.3.2" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03" + integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ== + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-interactive@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-interactive/-/is-interactive-1.0.0.tgz#cea6e6ae5c870a7b0a0004070b7b587e0252912e" + integrity sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w== + +is-unicode-supported@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" + integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +jackspeak@^2.0.3: + version "2.3.6" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-2.3.6.tgz#647ecc472238aee4b06ac0e461acc21a8c505ca8" + integrity sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ== + dependencies: + "@isaacs/cliui" "^8.0.2" + optionalDependencies: + "@pkgjs/parseargs" "^0.11.0" + +jackspeak@^3.1.2: + version "3.4.3" + resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-3.4.3.tgz#8833a9d89ab4acde6188942bd1c53b6390ed5a8a" + integrity sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw== + dependencies: + "@isaacs/cliui" "^8.0.2" + optionalDependencies: + "@pkgjs/parseargs" "^0.11.0" + +js-beautify@^1.14.11: + version "1.15.4" + resolved "https://registry.yarnpkg.com/js-beautify/-/js-beautify-1.15.4.tgz#f579f977ed4c930cef73af8f98f3f0a608acd51e" + integrity sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA== + dependencies: + config-chain "^1.1.13" + editorconfig "^1.0.4" + glob "^10.4.2" + js-cookie "^3.0.5" + nopt "^7.2.1" + +js-cookie@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/js-cookie/-/js-cookie-3.0.5.tgz#0b7e2fd0c01552c58ba86e0841f94dc2557dcdbc" + integrity sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw== + +js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +jsesc@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.1.0.tgz#74d335a234f67ed19907fdadfac7ccf9d409825d" + integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA== + +json5@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" + integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== + +leac@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/leac/-/leac-0.6.0.tgz#dcf136e382e666bd2475f44a1096061b70dc0912" + integrity sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg== + +log-symbols@4.1.0, log-symbols@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" + integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== + dependencies: + chalk "^4.1.0" + is-unicode-supported "^0.1.0" + +lru-cache@^10.2.0: + version "10.4.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" + integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== + +lru-cache@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" + integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== + dependencies: + yallist "^3.0.2" + +marked@7.0.4: + version "7.0.4" + resolved "https://registry.yarnpkg.com/marked/-/marked-7.0.4.tgz#e2558ee2d535b9df6a27c6e282dc603a18388a6d" + integrity sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ== + +md-to-react-email@5.0.5: + version "5.0.5" + resolved "https://registry.yarnpkg.com/md-to-react-email/-/md-to-react-email-5.0.5.tgz#1306b2454afe530526e3df42e7f8903fdf6ab07f" + integrity sha512-OvAXqwq57uOk+WZqFFNCMZz8yDp8BD3WazW1wAKHUrPbbdr89K9DWS6JXY09vd9xNdPNeurI8DU/X4flcfaD8A== + dependencies: + marked "7.0.4" + +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-types@2.1.35, mime-types@~2.1.34: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + +mimic-fn@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" + integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== + +minimatch@9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.1.tgz#8a555f541cf976c622daf078bb28f29fb927c253" + integrity sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w== + dependencies: + brace-expansion "^2.0.1" + +minimatch@^9.0.1, minimatch@^9.0.4: + version "9.0.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" + integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== + dependencies: + brace-expansion "^2.0.1" + +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" + integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== + +ms@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +nanoid@^3.3.6: + version "3.3.9" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.9.tgz#e0097d8e026b3343ff053e9ccd407360a03f503a" + integrity sha512-SppoicMGpZvbF1l3z4x7No3OlIjP7QJvC9XR7AhZr1kL133KHnKPztkKDc+Ir4aJ/1VhTySrtKhrsycmrMQfvg== + +negotiator@0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" + integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== + +next@15.1.2: + version "15.1.2" + resolved "https://registry.yarnpkg.com/next/-/next-15.1.2.tgz#305d093a9f3d6900b53fa4abb5b213264b22047c" + integrity sha512-nLJDV7peNy+0oHlmY2JZjzMfJ8Aj0/dd3jCwSZS8ZiO5nkQfcZRqDrRN3U5rJtqVTQneIOGZzb6LCNrk7trMCQ== + dependencies: + "@next/env" "15.1.2" + "@swc/counter" "0.1.3" + "@swc/helpers" "0.5.15" + busboy "1.6.0" + caniuse-lite "^1.0.30001579" + postcss "8.4.31" + styled-jsx "5.1.6" + optionalDependencies: + "@next/swc-darwin-arm64" "15.1.2" + "@next/swc-darwin-x64" "15.1.2" + "@next/swc-linux-arm64-gnu" "15.1.2" + "@next/swc-linux-arm64-musl" "15.1.2" + "@next/swc-linux-x64-gnu" "15.1.2" + "@next/swc-linux-x64-musl" "15.1.2" + "@next/swc-win32-arm64-msvc" "15.1.2" + "@next/swc-win32-x64-msvc" "15.1.2" + sharp "^0.33.5" + +node-releases@^2.0.19: + version "2.0.19" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.19.tgz#9e445a52950951ec4d177d843af370b411caf314" + integrity sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw== + +nopt@^7.2.1: + version "7.2.1" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-7.2.1.tgz#1cac0eab9b8e97c9093338446eddd40b2c8ca1e7" + integrity sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w== + dependencies: + abbrev "^2.0.0" + +normalize-path@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + +object-assign@^4: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== + +onetime@^5.1.0: + version "5.1.2" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" + integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== + dependencies: + mimic-fn "^2.1.0" + +ora@5.4.1: + version "5.4.1" + resolved "https://registry.yarnpkg.com/ora/-/ora-5.4.1.tgz#1b2678426af4ac4a509008e5e4ac9e9959db9e18" + integrity sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ== + dependencies: + bl "^4.1.0" + chalk "^4.1.0" + cli-cursor "^3.1.0" + cli-spinners "^2.5.0" + is-interactive "^1.0.0" + is-unicode-supported "^0.1.0" + log-symbols "^4.1.0" + strip-ansi "^6.0.0" + wcwidth "^1.0.1" + +package-json-from-dist@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" + integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== + +parseley@^0.12.0: + version "0.12.1" + resolved "https://registry.yarnpkg.com/parseley/-/parseley-0.12.1.tgz#4afd561d50215ebe259e3e7a853e62f600683aef" + integrity sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw== + dependencies: + leac "^0.6.0" + peberminta "^0.9.0" + +path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-scurry@^1.10.1, path-scurry@^1.11.1: + version "1.11.1" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.11.1.tgz#7960a668888594a0720b12a911d1a742ab9f11d2" + integrity sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA== + dependencies: + lru-cache "^10.2.0" + minipass "^5.0.0 || ^6.0.2 || ^7.0.0" + +peberminta@^0.9.0: + version "0.9.0" + resolved "https://registry.yarnpkg.com/peberminta/-/peberminta-0.9.0.tgz#8ec9bc0eb84b7d368126e71ce9033501dca2a352" + integrity sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ== + +picocolors@^1.0.0, picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +postcss@8.4.31: + version "8.4.31" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.31.tgz#92b451050a9f914da6755af352bdc0192508656d" + integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ== + dependencies: + nanoid "^3.3.6" + picocolors "^1.0.0" + source-map-js "^1.0.2" + +prettier@3.4.2: + version "3.4.2" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.4.2.tgz#a5ce1fb522a588bf2b78ca44c6e6fe5aa5a2b13f" + integrity sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ== + +prismjs@1.29.0: + version "1.29.0" + resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.29.0.tgz#f113555a8fa9b57c35e637bba27509dcf802dd12" + integrity sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q== + +proto-list@~1.2.1: + version "1.2.4" + resolved "https://registry.yarnpkg.com/proto-list/-/proto-list-1.2.4.tgz#212d5bfe1318306a420f6402b8e26ff39647a849" + integrity sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA== + +react-dom@19.0.0: + version "19.0.0" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.0.0.tgz#43446f1f01c65a4cd7f7588083e686a6726cfb57" + integrity sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ== + dependencies: + scheduler "^0.25.0" + +react-email@3.0.7: + version "3.0.7" + resolved "https://registry.yarnpkg.com/react-email/-/react-email-3.0.7.tgz#8b52684f157c5d5e6200bc201590827aaf9dc9ec" + integrity sha512-lX9dFCPtTG+79aP9uTdx763byshptPYbOi0KXwxn6nPJoDP/Ty/G1W5fx1lbrmec+pk38MTDZPrzJ/UYIxgP/Q== + dependencies: + "@babel/core" "7.24.5" + "@babel/parser" "7.24.5" + chalk "4.1.2" + chokidar "4.0.3" + commander "11.1.0" + debounce "2.0.0" + esbuild "0.23.0" + glob "10.3.4" + log-symbols "4.1.0" + mime-types "2.1.35" + next "15.1.2" + normalize-path "3.0.0" + ora "5.4.1" + socket.io "4.8.1" + +react-promise-suspense@0.3.4: + version "0.3.4" + resolved "https://registry.yarnpkg.com/react-promise-suspense/-/react-promise-suspense-0.3.4.tgz#05d19a75703d71374674840056cfef2fcd38809d" + integrity sha512-I42jl7L3Ze6kZaq+7zXWSunBa3b1on5yfvUW6Eo/3fFOj6dZ5Bqmcd264nJbTK/gn1HjjILAjSwnZbV4RpSaNQ== + dependencies: + fast-deep-equal "^2.0.1" + +react@19.0.0: + version "19.0.0" + resolved "https://registry.yarnpkg.com/react/-/react-19.0.0.tgz#6e1969251b9f108870aa4bff37a0ce9ddfaaabdd" + integrity sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ== + +readable-stream@^3.4.0: + version "3.6.2" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" + integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== + dependencies: + inherits "^2.0.3" + string_decoder "^1.1.1" + util-deprecate "^1.0.1" + +readdirp@^4.0.1: + version "4.1.2" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d" + integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg== + +resend@4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/resend/-/resend-4.1.2.tgz#fe05294f67c70c077b499bf27477226bf2cd3a8b" + integrity sha512-km0btrAj/BqIaRlS+SoLNMaCAUUWEgcEvZpycfVvoXEwAHCxU+vp/ikxPgKRkyKyiR2iDcdUq5uIBTDK9oSSSQ== + dependencies: + "@react-email/render" "1.0.1" + +restore-cursor@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e" + integrity sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA== + dependencies: + onetime "^5.1.0" + signal-exit "^3.0.2" + +safe-buffer@~5.2.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +scheduler@^0.25.0: + version "0.25.0" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.25.0.tgz#336cd9768e8cceebf52d3c80e3dcf5de23e7e015" + integrity sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA== + +selderee@^0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/selderee/-/selderee-0.11.0.tgz#6af0c7983e073ad3e35787ffe20cefd9daf0ec8a" + integrity sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA== + dependencies: + parseley "^0.12.0" + +semver@^6.3.1: + version "6.3.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" + integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== + +semver@^7.5.3, semver@^7.6.3: + version "7.7.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.1.tgz#abd5098d82b18c6c81f6074ff2647fd3e7220c9f" + integrity sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA== + +sharp@^0.33.5: + version "0.33.5" + resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.33.5.tgz#13e0e4130cc309d6a9497596715240b2ec0c594e" + integrity sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw== + dependencies: + color "^4.2.3" + detect-libc "^2.0.3" + semver "^7.6.3" + optionalDependencies: + "@img/sharp-darwin-arm64" "0.33.5" + "@img/sharp-darwin-x64" "0.33.5" + "@img/sharp-libvips-darwin-arm64" "1.0.4" + "@img/sharp-libvips-darwin-x64" "1.0.4" + "@img/sharp-libvips-linux-arm" "1.0.5" + "@img/sharp-libvips-linux-arm64" "1.0.4" + "@img/sharp-libvips-linux-s390x" "1.0.4" + "@img/sharp-libvips-linux-x64" "1.0.4" + "@img/sharp-libvips-linuxmusl-arm64" "1.0.4" + "@img/sharp-libvips-linuxmusl-x64" "1.0.4" + "@img/sharp-linux-arm" "0.33.5" + "@img/sharp-linux-arm64" "0.33.5" + "@img/sharp-linux-s390x" "0.33.5" + "@img/sharp-linux-x64" "0.33.5" + "@img/sharp-linuxmusl-arm64" "0.33.5" + "@img/sharp-linuxmusl-x64" "0.33.5" + "@img/sharp-wasm32" "0.33.5" + "@img/sharp-win32-ia32" "0.33.5" + "@img/sharp-win32-x64" "0.33.5" + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +signal-exit@^3.0.2: + version "3.0.7" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" + integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== + +signal-exit@^4.0.1: + version "4.1.0" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-4.1.0.tgz#952188c1cbd546070e2dd20d0f41c0ae0530cb04" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== + +simple-swizzle@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a" + integrity sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg== + dependencies: + is-arrayish "^0.3.1" + +socket.io-adapter@~2.5.2: + version "2.5.5" + resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz#c7a1f9c703d7756844751b6ff9abfc1780664082" + integrity sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg== + dependencies: + debug "~4.3.4" + ws "~8.17.1" + +socket.io-parser@~4.2.4: + version "4.2.4" + resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.4.tgz#c806966cf7270601e47469ddeec30fbdfda44c83" + integrity sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew== + dependencies: + "@socket.io/component-emitter" "~3.1.0" + debug "~4.3.1" + +socket.io@4.8.1: + version "4.8.1" + resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-4.8.1.tgz#fa0eaff965cc97fdf4245e8d4794618459f7558a" + integrity sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg== + dependencies: + accepts "~1.3.4" + base64id "~2.0.0" + cors "~2.8.5" + debug "~4.3.2" + engine.io "~6.6.0" + socket.io-adapter "~2.5.2" + socket.io-parser "~4.2.4" + +source-map-js@^1.0.2: + version "1.2.1" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + +streamsearch@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" + integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== + +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^5.0.1, string-width@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" + integrity sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA== + dependencies: + eastasianwidth "^0.2.0" + emoji-regex "^9.2.2" + strip-ansi "^7.0.1" + +string_decoder@^1.1.1: + version "1.3.0" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.3.0.tgz#42f114594a46cf1a8e30b0a84f56c78c3edac21e" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^7.0.1: + version "7.1.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" + integrity sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ== + dependencies: + ansi-regex "^6.0.1" + +styled-jsx@5.1.6: + version "5.1.6" + resolved "https://registry.yarnpkg.com/styled-jsx/-/styled-jsx-5.1.6.tgz#83b90c077e6c6a80f7f5e8781d0f311b2fe41499" + integrity sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA== + dependencies: + client-only "0.0.1" + +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +tslib@^2.4.0, tslib@^2.8.0: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + +undici-types@~6.20.0: + version "6.20.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.20.0.tgz#8171bf22c1f588d1554d55bf204bc624af388433" + integrity sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg== + +update-browserslist-db@^1.1.1: + version "1.1.3" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz#348377dd245216f9e7060ff50b15a1b740b75420" + integrity sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw== + dependencies: + escalade "^3.2.0" + picocolors "^1.1.1" + +util-deprecate@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== + +vary@^1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" + integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== + +wcwidth@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/wcwidth/-/wcwidth-1.0.1.tgz#f0b0dcf915bc5ff1528afadb2c0e17b532da2fe8" + integrity sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg== + dependencies: + defaults "^1.0.3" + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrap-ansi@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" + integrity sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ== + dependencies: + ansi-styles "^6.1.0" + string-width "^5.0.1" + strip-ansi "^7.0.1" + +ws@~8.17.1: + version "8.17.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b" + integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ== + +yallist@^3.0.2: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" + integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== diff --git a/backend/firebase/.firebaserc b/backend/firebase/.firebaserc new file mode 100644 index 0000000..453b367 --- /dev/null +++ b/backend/firebase/.firebaserc @@ -0,0 +1,7 @@ +{ + "projects": { + "default": "polylove", + "prod": "polylove", + "dev": "polylove-dev" + } +} diff --git a/backend/firebase/firebase.json b/backend/firebase/firebase.json new file mode 100644 index 0000000..326e01e --- /dev/null +++ b/backend/firebase/firebase.json @@ -0,0 +1,12 @@ +{ + "storage": [ + { + "bucket": "polylove.firebasestorage.app", + "rules": "storage.rules" + }, + { + "bucket": "polylove-private.firebasestorage.app", + "rules": "private-storage.rules" + } + ] +} diff --git a/backend/firebase/private-storage.rules b/backend/firebase/private-storage.rules new file mode 100644 index 0000000..ef1c27f --- /dev/null +++ b/backend/firebase/private-storage.rules @@ -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 + } + } +} \ No newline at end of file diff --git a/backend/firebase/storage.rules b/backend/firebase/storage.rules new file mode 100644 index 0000000..85f0e6e --- /dev/null +++ b/backend/firebase/storage.rules @@ -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 + } + } +} diff --git a/backend/scripts/2024-03-10-migrate_dm_cols.sql b/backend/scripts/2024-03-10-migrate_dm_cols.sql new file mode 100644 index 0000000..067afcb --- /dev/null +++ b/backend/scripts/2024-03-10-migrate_dm_cols.sql @@ -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; + diff --git a/backend/scripts/2025-04-23-migrate-social-links.ts b/backend/scripts/2025-04-23-migrate-social-links.ts new file mode 100644 index 0000000..edc65d4 --- /dev/null +++ b/backend/scripts/2025-04-23-migrate-social-links.ts @@ -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` + ) +}) diff --git a/backend/scripts/2025-04-26-init-empty-social-links.sql b/backend/scripts/2025-04-26-init-empty-social-links.sql new file mode 100644 index 0000000..fb7cf88 --- /dev/null +++ b/backend/scripts/2025-04-26-init-empty-social-links.sql @@ -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 diff --git a/backend/scripts/find-tiptap-nodes.ts b/backend/scripts/find-tiptap-nodes.ts new file mode 100644 index 0000000..ebabf4d --- /dev/null +++ b/backend/scripts/find-tiptap-nodes.ts @@ -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)) + }) +} diff --git a/backend/scripts/import-love-finalize.sql b/backend/scripts/import-love-finalize.sql new file mode 100644 index 0000000..122c5fd --- /dev/null +++ b/backend/scripts/import-love-finalize.sql @@ -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); diff --git a/backend/scripts/import-love-tables.sh b/backend/scripts/import-love-tables.sh new file mode 100755 index 0000000..93c7a3b --- /dev/null +++ b/backend/scripts/import-love-tables.sh @@ -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" +) \ No newline at end of file diff --git a/backend/scripts/regen-schema.ts b/backend/scripts/regen-schema.ts new file mode 100644 index 0000000..436efe5 --- /dev/null +++ b/backend/scripts/regen-schema.ts @@ -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` + ) +} diff --git a/backend/scripts/remove-tiptap-nodes.ts b/backend/scripts/remove-tiptap-nodes.ts new file mode 100644 index 0000000..102a1f8 --- /dev/null +++ b/backend/scripts/remove-tiptap-nodes.ts @@ -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) + } +}) diff --git a/backend/scripts/run-script.ts b/backend/scripts/run-script.ts new file mode 100644 index 0000000..9b111e9 --- /dev/null +++ b/backend/scripts/run-script.ts @@ -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 +) => { + const env = getLocalEnv() + const credentials = getServiceAccountCredentials(env) + + await loadSecretsToEnv(credentials) + + const pg = createSupabaseDirectClient() + await main({ pg }) + + process.exit() +} diff --git a/backend/scripts/tsconfig.json b/backend/scripts/tsconfig.json new file mode 100644 index 0000000..10776c6 --- /dev/null +++ b/backend/scripts/tsconfig.json @@ -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 +} diff --git a/backend/shared/.eslintrc.js b/backend/shared/.eslintrc.js new file mode 100644 index 0000000..db48f75 --- /dev/null +++ b/backend/shared/.eslintrc.js @@ -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'], + }, +} diff --git a/backend/shared/.gitignore b/backend/shared/.gitignore new file mode 100644 index 0000000..70b98f2 --- /dev/null +++ b/backend/shared/.gitignore @@ -0,0 +1,2 @@ +# Compiled JavaScript files +lib/ diff --git a/backend/shared/package.json b/backend/shared/package.json new file mode 100644 index 0000000..e88d003 --- /dev/null +++ b/backend/shared/package.json @@ -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" + } +} diff --git a/backend/shared/src/analytics.ts b/backend/shared/src/analytics.ts new file mode 100644 index 0000000..6b290d9 --- /dev/null +++ b/backend/shared/src/analytics.ts @@ -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 ?? '' +} diff --git a/backend/shared/src/audit-events.ts b/backend/shared/src/audit-events.ts new file mode 100644 index 0000000..d116012 --- /dev/null +++ b/backend/shared/src/audit-events.ts @@ -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 +) => { + 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] + ) + ) +} diff --git a/backend/shared/src/create-love-notification.ts b/backend/shared/src/create-love-notification.ts new file mode 100644 index 0000000..756b174 --- /dev/null +++ b/backend/shared/src/create-love-notification.ts @@ -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) +} diff --git a/backend/shared/src/helpers/auth.ts b/backend/shared/src/helpers/auth.ts new file mode 100644 index 0000000..240efdc --- /dev/null +++ b/backend/shared/src/helpers/auth.ts @@ -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.` + ) + } +} diff --git a/backend/shared/src/helpers/generate-and-update-avatar-urls.ts b/backend/shared/src/helpers/generate-and-update-avatar-urls.ts new file mode 100644 index 0000000..f498274 --- /dev/null +++ b/backend/shared/src/helpers/generate-and-update-avatar-urls.ts @@ -0,0 +1,50 @@ +import { Storage } from 'firebase-admin/storage' +import { DOMAIN } from 'common/envs/constants' + +type Bucket = ReturnType['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}` +} diff --git a/backend/shared/src/helpers/search.ts b/backend/shared/src/helpers/search.ts new file mode 100644 index 0000000..4f32c15 --- /dev/null +++ b/backend/shared/src/helpers/search.ts @@ -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 +} diff --git a/backend/shared/src/helpers/try-or-log-error.ts b/backend/shared/src/helpers/try-or-log-error.ts new file mode 100644 index 0000000..244bdfd --- /dev/null +++ b/backend/shared/src/helpers/try-or-log-error.ts @@ -0,0 +1,10 @@ +import { log } from 'shared/utils' + +export const tryOrLogError = async (task: Promise) => { + try { + return await task + } catch (e) { + log.error(e) + return null + } +} diff --git a/backend/shared/src/init-admin.ts b/backend/shared/src/init-admin.ts new file mode 100644 index 0000000..102db88 --- /dev/null +++ b/backend/shared/src/init-admin.ts @@ -0,0 +1,27 @@ +import * as admin from 'firebase-admin' + +import { getServiceAccountCredentials } from 'common/secrets' + +export const getLocalEnv = () => { + return (process.env.ENV?.toUpperCase() ?? 'STAGING') as 'PROD' | 'DEV' +} + +// Locally initialize Firebase Admin. +export const initAdmin = () => { + try { + const env = getLocalEnv() + const serviceAccount = getServiceAccountCredentials(env) + console.log( + `Initializing connection to ${serviceAccount.project_id} Firebase...` + ) + return admin.initializeApp({ + projectId: serviceAccount.project_id, + credential: admin.credential.cert(serviceAccount), + storageBucket: `${serviceAccount.project_id}.appspot.com`, + }) + } catch (err) { + console.error(err) + console.log(`Initializing connection to default Firebase...`) + return admin.initializeApp() + } +} diff --git a/backend/shared/src/love/parse-photos.ts b/backend/shared/src/love/parse-photos.ts new file mode 100644 index 0000000..633407a --- /dev/null +++ b/backend/shared/src/love/parse-photos.ts @@ -0,0 +1,10 @@ +export const removePinnedUrlFromPhotoUrls = async (parsedBody: { + pinned_url?: string + photo_urls?: string[] +}) => { + if (parsedBody.photo_urls && parsedBody.pinned_url) { + parsedBody.photo_urls = parsedBody.photo_urls.filter( + (url: string) => url !== parsedBody.pinned_url + ) + } +} diff --git a/backend/shared/src/love/supabase.ts b/backend/shared/src/love/supabase.ts new file mode 100644 index 0000000..ab00cd6 --- /dev/null +++ b/backend/shared/src/love/supabase.ts @@ -0,0 +1,128 @@ +import { areGenderCompatible } from 'common/love/compatibility-util' +import { type Lover, type LoverRow } from 'common/love/lover' +import { type User } from 'common/user' +import { Row } from 'common/supabase/utils' +import { createSupabaseDirectClient } from 'shared/supabase/init' + +export type LoverAndUserRow = LoverRow & { + name: string + username: string + user: any +} + +export function convertRow(row: LoverAndUserRow): Lover +export function convertRow(row: LoverAndUserRow | undefined): Lover | null { + if (!row) return null + + return { + ...row, + user: { ...row.user, name: row.name, username: row.username } as User, + } as Lover +} + +const LOVER_COLS = 'lovers.*, name, username, users.data as user' + +export const getLover = async (userId: string) => { + const pg = createSupabaseDirectClient() + return await pg.oneOrNone( + ` + select + ${LOVER_COLS} + from + lovers + join + users on users.id = lovers.user_id + where + user_id = $1 + `, + [userId], + convertRow + ) +} + +export const getLovers = async (userIds: string[]) => { + const pg = createSupabaseDirectClient() + return await pg.map( + ` + select + ${LOVER_COLS} + from + lovers + join + users on users.id = lovers.user_id + where + user_id = any($1) + `, + [userIds], + convertRow + ) +} + +export const getGenderCompatibleLovers = async (lover: LoverRow) => { + const pg = createSupabaseDirectClient() + const lovers = await pg.map( + ` + select + ${LOVER_COLS} + from lovers + join + users on users.id = lovers.user_id + where + user_id != $(user_id) + and looking_for_matches + and (data->>'isBannedFromPosting' != 'true' or data->>'isBannedFromPosting' is null) + and (data->>'userDeleted' != 'true' or data->>'userDeleted' is null) + and lovers.pinned_url is not null + `, + { ...lover }, + convertRow + ) + return lovers.filter((l: Lover) => areGenderCompatible(lover, l)) +} + +export const getCompatibleLovers = async ( + lover: LoverRow, + radiusKm: number | undefined +) => { + const pg = createSupabaseDirectClient() + return await pg.map( + ` + select + ${LOVER_COLS} + from lovers + join + users on users.id = lovers.user_id + where + user_id != $(user_id) + and looking_for_matches + and (data->>'isBannedFromPosting' != 'true' or data->>'isBannedFromPosting' is null) + and (data->>'userDeleted' != 'true' or data->>'userDeleted' is null) + + -- Gender + and (lovers.gender = any($(pref_gender)) or lovers.gender = 'non-binary') + and ($(gender) = any(lovers.pref_gender) or $(gender) = 'non-binary') + + -- Age + and lovers.age >= $(pref_age_min) + and lovers.age <= $(pref_age_max) + and $(age) >= lovers.pref_age_min + and $(age) <= lovers.pref_age_max + + -- Location + and calculate_earth_distance_km($(city_latitude), $(city_longitude), lovers.city_latitude, lovers.city_longitude) < $(radiusKm) + `, + { ...lover, radiusKm: radiusKm ?? 40_000 }, + convertRow + ) +} + +export const getCompatibilityAnswers = async (userIds: string[]) => { + const pg = createSupabaseDirectClient() + return await pg.manyOrNone>( + ` + select * from love_compatibility_answers + where creator_id = any($1) + `, + [userIds] + ) +} diff --git a/backend/shared/src/monitoring/context.ts b/backend/shared/src/monitoring/context.ts new file mode 100644 index 0000000..e7a3905 --- /dev/null +++ b/backend/shared/src/monitoring/context.ts @@ -0,0 +1,28 @@ +// Shared contextual information that metrics and logging can use, e.g. +// the scheduler job or HTTP request endpoint currently running. + +import { AsyncLocalStorage } from 'node:async_hooks' + +export type ContextDetails = Record + +export type JobContext = ContextDetails & { + job: string + traceId: string +} + +export type RequestContext = ContextDetails & { + endpoint: string + traceId: string +} + +export type MonitoringContext = JobContext | RequestContext + +export const STORE = new AsyncLocalStorage() + +export function withMonitoringContext(ctx: MonitoringContext, fn: () => R) { + return STORE.run(ctx, fn) +} + +export function getMonitoringContext() { + return STORE.getStore() +} diff --git a/backend/shared/src/monitoring/instance-info.ts b/backend/shared/src/monitoring/instance-info.ts new file mode 100644 index 0000000..366c036 --- /dev/null +++ b/backend/shared/src/monitoring/instance-info.ts @@ -0,0 +1,20 @@ +import { last } from 'lodash' +import * as metadata from 'gcp-metadata' + +export type InstanceInfo = { + projectId: string + instanceId: string + zone: string +} + +/** Gets GCP instance info from the local instance metadata service. */ +export async function getInstanceInfo() { + const [projectId, instanceId, fqZone] = await Promise.all([ + metadata.project('project-id'), + metadata.instance('id'), + metadata.instance('zone'), + ]) + // GCP returns zone as `projects/${id}/zones/${zone} + const zone = last(fqZone.split('/')) + return { projectId, instanceId, zone } as InstanceInfo +} diff --git a/backend/shared/src/monitoring/log.ts b/backend/shared/src/monitoring/log.ts new file mode 100644 index 0000000..f2851af --- /dev/null +++ b/backend/shared/src/monitoring/log.ts @@ -0,0 +1,115 @@ +import { format } from 'node:util' +import { isError, pick, omit } from 'lodash' +import { dim, red, yellow } from 'colors/safe' +import { getMonitoringContext } from './context' + +// mapping JS log levels (e.g. functions on console object) to GCP log levels +const JS_TO_GCP_LEVELS = { + debug: 'DEBUG', + info: 'INFO', + warn: 'WARNING', + error: 'ERROR', +} as const + +const JS_LEVELS = Object.keys(JS_TO_GCP_LEVELS) as LogLevel[] +const DEFAULT_LEVEL = 'info' +const IS_GCP = process.env.GOOGLE_CLOUD_PROJECT != null + +// keys to put in front to categorize a log line in the console +const DISPLAY_CATEGORY_KEYS = ['endpoint', 'job'] as const + +// keys to ignore when printing out log details in the console +const DISPLAY_EXCLUDED_KEYS = ['traceId'] as const + +export type LogLevel = keyof typeof JS_TO_GCP_LEVELS +export type LogDetails = Record +export type TextLogger = (msg: unknown, ...args: unknown[]) => void +export type StructuredLogger = (msg: unknown, props?: LogDetails) => void +export type Logger = TextLogger & { + [Property in LogLevel]: StructuredLogger +} + +function toString(obj: unknown) { + if (isError(obj)) { + return obj.stack ?? obj.message // stack is formatted like "Error: message\n[stack]" + } else { + return String(obj) + } +} + +function replacer(_key: string, value: unknown) { + if (typeof value === 'bigint') { + return value.toString() + } else if (isError(value)) { + return { + // custom enumerable properties on error, like e.g. status code on APIErrors + ...value, + // these properties aren't enumerable so we need to include them explicitly + // see https://stackoverflow.com/questions/18391212/ + name: value.name, + message: value.message, + stack: value.stack, + } + } else { + return value + } +} + +function ts() { + return `[${new Date().toISOString()}]` +} + +// handles both the cases where someone wants to write unstructured +// stream-of-consciousness console logging like log('count:', 1, 'user': u) +// and also structured key/value logging with severity +function writeLog( + level: LogLevel, + msg: unknown, + opts?: { props?: LogDetails; rest?: unknown[] } +) { + try { + const { props, rest } = opts ?? {} + const contextData = getMonitoringContext() + const message = format(toString(msg), ...(rest ?? [])) + const data = { ...(contextData ?? {}), ...(props ?? {}) } + if (IS_GCP) { + const severity = JS_TO_GCP_LEVELS[level] + const output: LogDetails = { severity, message, ...data } + if (msg instanceof Error) { + // record error properties in GCP if you just do log(err) + output['error'] = msg + } + console.log(JSON.stringify(output, replacer)) + } else { + const category = Object.values(pick(data, DISPLAY_CATEGORY_KEYS)).join() + const categoryLabel = category ? dim(category) + ' ' : '' + const details = Object.entries( + omit(data, [...DISPLAY_CATEGORY_KEYS, ...DISPLAY_EXCLUDED_KEYS]) + ).map(([key, value]) => `\n ${key}: ${JSON.stringify(value)}`) + const result = `${dim(ts())} ${categoryLabel}${message}${details}` + if (level === 'error') { + return console.error(red(result)) + } else if (level === 'warn') { + return console.warn(yellow(result)) + } else if (level === 'debug') { + return console.debug(dim(result)) + } else { + return console[level](result) + } + } + } catch (e) { + console.error('Could not write log output.', e) + } +} + +export function getLogger(): Logger { + const logger = ((msg: unknown, ...rest: unknown[]) => + writeLog(DEFAULT_LEVEL, msg, { rest })) as Logger + for (const level of JS_LEVELS) { + logger[level] = (msg: unknown, props?: LogDetails) => + writeLog(level, msg, { props }) + } + return logger +} + +export const log = getLogger() diff --git a/backend/shared/src/monitoring/metric-writer.ts b/backend/shared/src/monitoring/metric-writer.ts new file mode 100644 index 0000000..8f7d048 --- /dev/null +++ b/backend/shared/src/monitoring/metric-writer.ts @@ -0,0 +1,152 @@ +import { MetricServiceClient } from '@google-cloud/monitoring' +import { average, sumOfSquaredError } from 'common/util/math' +import { log } from './log' +import { InstanceInfo, getInstanceInfo } from './instance-info' +import { chunk } from 'lodash' +import { + CUSTOM_METRICS, + MetricStore, + MetricStoreEntry, + metrics, +} from './metrics' + +// how often metrics are written. GCP says don't write for a single time series +// more than once per 5 seconds. +export const METRICS_INTERVAL_MS = 60_000 + +const LOCAL_DEV = process.env.GOOGLE_CLOUD_PROJECT == null + +function serializeTimestamp(ts: number) { + const seconds = ts / 1000 + const nanos = (ts % 1000) * 1000 + return { seconds, nanos } as const +} + +// see https://cloud.google.com/monitoring/api/ref_v3/rest/v3/projects.snoozes#timeinterval +function serializeInterval(entry: MetricStoreEntry, ts: number) { + switch (CUSTOM_METRICS[entry.type].metricKind) { + case 'CUMULATIVE': + return { + startTime: serializeTimestamp(entry.startTime), + endTime: serializeTimestamp(ts), + } + case 'GAUGE': { + return { endTime: serializeTimestamp(ts) } + } + } +} + +function serializeDistribution(points: number[]) { + // see https://cloud.google.com/monitoring/api/ref_v3/rest/v3/TypedValue#distribution + return { + count: points.length, + mean: average(points), + sumOfSquaredDeviation: sumOfSquaredError(points), + // not interested in handling histograms right now + bucketOptions: { explicitBuckets: { bounds: [0] } }, + bucketCounts: [0, points.length], + } +} + +// see https://cloud.google.com/monitoring/api/ref_v3/rest/v3/TypedValue +function serializeValue(entry: MetricStoreEntry) { + switch (CUSTOM_METRICS[entry.type].valueKind) { + case 'int64Value': + return { int64Value: entry.value } + case 'distributionValue': { + return { distributionValue: serializeDistribution(entry.points ?? []) } + } + default: + throw new Error('Other value kinds not yet implemented.') + } +} + +// see https://cloud.google.com/monitoring/api/ref_v3/rest/v3/TimeSeries +function serializeEntries( + instance: InstanceInfo, + entries: MetricStoreEntry[], + ts: number +) { + return entries.map((entry) => ({ + metricKind: CUSTOM_METRICS[entry.type].metricKind, + resource: { + type: 'gce_instance', + labels: { + project_id: instance.projectId, + instance_id: instance.instanceId, + zone: instance.zone, + }, + }, + metric: { + type: `custom.googleapis.com/${entry.type}`, + labels: entry.labels ?? {}, + }, + points: [ + { + interval: serializeInterval(entry, ts), + value: serializeValue(entry), + }, + ], + })) +} + +/** Writes metrics out to GCP's API from a metric store on an interval. */ +export class MetricWriter { + client: MetricServiceClient + store: MetricStore + intervalMs: number + instance?: InstanceInfo + runInterval?: NodeJS.Timeout + + constructor(store: MetricStore, intervalMs: number) { + this.client = new MetricServiceClient() + this.store = store + this.intervalMs = intervalMs + } + + async write() { + const freshEntries = this.store.freshEntries() + if (freshEntries.length > 0) { + for (const entry of freshEntries) { + entry.fresh = false + } + if (!LOCAL_DEV) { + log.debug('Writing GCP metrics.', { entries: freshEntries }) + if (this.instance == null) { + this.instance = await getInstanceInfo() + log.debug('Retrieved instance metadata.', { + instance: this.instance, + }) + } + // mqp: bump now by 1ms to avoid it being === to just written entry times + const now = Date.now() + 1 + const name = this.client.projectPath(this.instance.projectId) + const timeSeries = serializeEntries(this.instance, freshEntries, now) + // GCP imposes a max 200 per call limit + for (const batch of chunk(timeSeries, 200)) { + this.store.clearDistributionGauges() + // see https://cloud.google.com/monitoring/custom-metrics/creating-metrics + await this.client.createTimeSeries({ timeSeries: batch, name }) + } + } + } + } + + start() { + if (!this.runInterval) { + this.runInterval = setInterval(async () => { + try { + await this.write() + } catch (error) { + log.error('Failed to write metrics.', { error }) + } + }, this.intervalMs) + } + } + + stop() { + clearTimeout(this.runInterval) + } +} + +export const METRIC_WRITER = new MetricWriter(metrics, METRICS_INTERVAL_MS) diff --git a/backend/shared/src/monitoring/metrics.ts b/backend/shared/src/monitoring/metrics.ts new file mode 100644 index 0000000..b97fd3c --- /dev/null +++ b/backend/shared/src/monitoring/metrics.ts @@ -0,0 +1,172 @@ +import { isEqual, flatten } from 'lodash' + +// see https://cloud.google.com/monitoring/api/ref_v3/rest/v3/projects.metricDescriptors#MetricKind +export type MetricKind = 'GAUGE' | 'CUMULATIVE' + +// see https://cloud.google.com/monitoring/api/ref_v3/rest/v3/TypedValue +export type MetricValueKind = + | 'int64Value' + | 'doubleValue' + | 'stringValue' + | 'boolValue' + | 'distributionValue' + +export type MetricDescriptor = { + metricKind: MetricKind + valueKind: MetricValueKind +} + +export type MetricLabels = Record + +export const CUSTOM_METRICS = { + 'ws/open_connections': { + metricKind: 'GAUGE', + valueKind: 'int64Value', + }, + 'ws/connections_established': { + metricKind: 'CUMULATIVE', + valueKind: 'int64Value', + }, + 'ws/connections_terminated': { + metricKind: 'CUMULATIVE', + valueKind: 'int64Value', + }, + 'ws/broadcasts_sent': { + metricKind: 'CUMULATIVE', + valueKind: 'int64Value', + }, + 'http/request_count': { + metricKind: 'CUMULATIVE', + valueKind: 'int64Value', + }, + 'http/request_latency': { + metricKind: 'GAUGE', + valueKind: 'distributionValue', + }, + 'app/bet_count': { + metricKind: 'CUMULATIVE', + valueKind: 'int64Value', + }, + 'app/contract_view_count': { + metricKind: 'CUMULATIVE', + valueKind: 'int64Value', + }, + 'pg/query_count': { + metricKind: 'CUMULATIVE', + valueKind: 'int64Value', + }, + 'pg/connections_established': { + metricKind: 'CUMULATIVE', + valueKind: 'int64Value', + }, + 'pg/connections_terminated': { + metricKind: 'CUMULATIVE', + valueKind: 'int64Value', + }, + 'pg/connections_acquired': { + metricKind: 'CUMULATIVE', + valueKind: 'int64Value', + }, + 'pg/connections_released': { + metricKind: 'CUMULATIVE', + valueKind: 'int64Value', + }, + 'pg/pool_connections': { + metricKind: 'GAUGE', + valueKind: 'int64Value', + }, + 'vercel/revalidations_succeeded': { + metricKind: 'CUMULATIVE', + valueKind: 'int64Value', + }, + 'vercel/revalidations_failed': { + metricKind: 'CUMULATIVE', + valueKind: 'int64Value', + }, +} as const satisfies { [k: string]: MetricDescriptor } + +// the typing for all this could be way fancier, but seems overkill + +export type CustomMetrics = typeof CUSTOM_METRICS +export type MetricType = keyof CustomMetrics + +export type MetricStoreEntry = { + type: MetricType + labels?: MetricLabels + fresh: boolean // whether this metric was touched since last time + startTime: number // used for cumulative metrics + points?: number[] // used for distribution metrics + value: number +} + +/** Records metric values by type and labels for later export. */ +export class MetricStore { + // { name: [...entries of that metric with different label values] } + data: Map + + constructor() { + this.data = new Map() + } + + clear() { + this.data.clear() + } + + push(type: MetricType, val: number, labels?: MetricLabels) { + const entry = this.getOrCreate(type, labels) + let points = entry.points + if (points == null) { + points = entry.points = [] + } + points.push(val) + entry.fresh = true + } + + set(type: MetricType, val: number, labels?: MetricLabels) { + const entry = this.getOrCreate(type, labels) + entry.value = val + entry.fresh = true + } + + inc(type: MetricType, labels?: MetricLabels) { + const entry = this.getOrCreate(type, labels) + entry.value += 1 + entry.fresh = true + } + + freshEntries() { + return flatten( + Array.from(this.data.entries(), ([_, vs]) => vs.filter((e) => e.fresh)) + ) + } + + // mqp: we could clear all gauges but then we should centralize the process for polling + // them in order to not have weird gaps. + clearDistributionGauges() { + for (const k of this.data.keys()) { + const { metricKind, valueKind } = CUSTOM_METRICS[k] + if (metricKind === 'GAUGE' && valueKind === 'distributionValue') { + this.data.delete(k) + } + } + } + + getOrCreate(type: MetricType, labels?: MetricLabels) { + let entries = this.data.get(type) + if (entries == null) { + this.data.set(type, (entries = [])) + } + for (const entry of entries) { + if (isEqual(labels ?? {}, entry.labels ?? {})) { + return entry + } + } + // none exists, so create it + const entry = { type, labels, startTime: Date.now(), fresh: true, value: 0 } + entries.push(entry) + return entry as MetricStoreEntry + } +} + +/** The global metric store. */ +export const metrics = new MetricStore() diff --git a/backend/shared/src/supabase/init.ts b/backend/shared/src/supabase/init.ts new file mode 100644 index 0000000..b6495d0 --- /dev/null +++ b/backend/shared/src/supabase/init.ts @@ -0,0 +1,131 @@ +import pgPromise from 'pg-promise' +export { SupabaseClient } from 'common/supabase/utils' +import { DEV_CONFIG } from 'common/envs/dev' +import { PROD_CONFIG } from 'common/envs/prod' +import { metrics, log, isProd } from '../utils' +import { IDatabase, ITask } from 'pg-promise' +import { IClient } from 'pg-promise/typescript/pg-subset' +import { HOUR_MS } from 'common/util/time' +import { METRICS_INTERVAL_MS } from 'shared/monitoring/metric-writer' +import { getMonitoringContext } from 'shared/monitoring/context' +import { type IConnectionParameters } from 'pg-promise/typescript/pg-subset' + +export const pgp = pgPromise({ + error(err: any, e: pgPromise.IEventContext) { + // Read more: https://node-postgres.com/apis/pool#error + log.error('pgPromise background error', { + error: err, + event: e, + }) + }, + query() { + const ctx = getMonitoringContext() + if (ctx?.endpoint) { + metrics.inc('pg/query_count', { endpoint: ctx.endpoint }) + } else if (ctx?.job) { + metrics.inc('pg/query_count', { job: ctx.job }) + } else { + metrics.inc('pg/query_count') + } + }, +}) + +// This loses precision for large numbers (> 2^53). Beware fetching int8 columns with large values. +pgp.pg.types.setTypeParser(20, (value: any) => parseInt(value, 10)) +pgp.pg.types.setTypeParser(1700, parseFloat) // Type Id 1700 = NUMERIC + +export type SupabaseTransaction = ITask<{}> +export type SupabaseDirectClient = IDatabase<{}, IClient> | SupabaseTransaction + +export function getInstanceId() { + return ( + process.env.SUPABASE_INSTANCE_ID ?? + (isProd() ? PROD_CONFIG.supabaseInstanceId : DEV_CONFIG.supabaseInstanceId) + ) +} + +const newClient = ( + props: { + instanceId?: string + password?: string + } & IConnectionParameters +) => { + const { instanceId, password, ...settings } = props + + console.log({ + host: 'db.ltzepxnhhnrnvovqblfr.supabase.co', + port: 5432, + user: `postgres`, + password: password, + database: 'postgres', + ssl: { rejectUnauthorized: false }, + ...settings, + }) + + return pgp({ + host: 'db.ltzepxnhhnrnvovqblfr.supabase.co', + port: 5432, + user: `postgres`, + password: password, + database: 'postgres', + ssl: { rejectUnauthorized: false }, + ...settings, + }) +} + +// Use one connection to avoid WARNING: Creating a duplicate database object for the same connection. +let pgpDirect: IDatabase<{}, IClient> | null = null +export function createSupabaseDirectClient( + instanceId?: string, + password?: string +) { + if (pgpDirect) return pgpDirect + instanceId = instanceId ?? getInstanceId() + if (!instanceId) { + throw new Error( + "Can't connect to Supabase; no process.env.SUPABASE_INSTANCE_ID and no instance ID in config." + ) + } + password = password ?? process.env.SUPABASE_DB_PASSWORD + if (!password) { + throw new Error( + "Can't connect to Supabase; no process.env.SUPABASE_PASSWORD." + ) + } + const client = newClient({ + instanceId: getInstanceId(), + password: password, + query_timeout: HOUR_MS, // mqp: debugging scheduled job behavior + max: 20, + }) + const pool = client.$pool + pool.on('connect', () => metrics.inc('pg/connections_established')) + pool.on('remove', () => metrics.inc('pg/connections_terminated')) + pool.on('acquire', () => metrics.inc('pg/connections_acquired')) + pool.on('release', () => metrics.inc('pg/connections_released')) + setInterval(() => { + metrics.set('pg/pool_connections', pool.waitingCount, { state: 'waiting' }) + metrics.set('pg/pool_connections', pool.idleCount, { state: 'idle' }) + metrics.set('pg/pool_connections', pool.expiredCount, { state: 'expired' }) + metrics.set('pg/pool_connections', pool.totalCount, { state: 'total' }) + }, METRICS_INTERVAL_MS) + return (pgpDirect = client) +} + +let shortTimeoutPgpClient: IDatabase<{}, IClient> | null = null +export const createShortTimeoutDirectClient = () => { + if (shortTimeoutPgpClient) return shortTimeoutPgpClient + shortTimeoutPgpClient = newClient({ + instanceId: getInstanceId(), + password: process.env.SUPABASE_DB_PASSWORD, + query_timeout: 1000 * 30, + max: 20, + }) + return shortTimeoutPgpClient +} + +export const SERIAL_MODE = new pgp.txMode.TransactionMode({ + tiLevel: pgp.txMode.isolationLevel.serializable, + readOnly: false, + deferrable: false, +}) diff --git a/backend/shared/src/supabase/notifications.ts b/backend/shared/src/supabase/notifications.ts new file mode 100644 index 0000000..7566864 --- /dev/null +++ b/backend/shared/src/supabase/notifications.ts @@ -0,0 +1,33 @@ +import { Notification } from 'common/notifications' +import { SupabaseDirectClient } from 'shared/supabase/init' +import { broadcast } from 'shared/websockets/server' +import { bulkInsert } from 'shared/supabase/utils' + +export const insertNotificationToSupabase = async ( + notification: Notification, + pg: SupabaseDirectClient +) => { + await pg.none( + `insert into postgres.public.user_notifications (user_id, notification_id, data) values ($1, $2, $3) on conflict do nothing`, + [notification.userId, notification.id, notification] + ) + broadcast(`user-notifications/${notification.userId}`, { notification }) +} + +export const bulkInsertNotifications = async ( + notifications: Notification[], + pg: SupabaseDirectClient +) => { + await bulkInsert( + pg, + 'user_notifications', + notifications.map((n) => ({ + user_id: n.userId, + notification_id: n.id, + data: n, + })) + ) + notifications.forEach((notification) => + broadcast(`user-notifications/${notification.userId}`, { notification }) + ) +} diff --git a/backend/shared/src/supabase/sql-builder.ts b/backend/shared/src/supabase/sql-builder.ts new file mode 100644 index 0000000..ea8e2ed --- /dev/null +++ b/backend/shared/src/supabase/sql-builder.ts @@ -0,0 +1,119 @@ +import { last } from 'lodash' +import { buildArray } from 'common/util/array' +import { pgp } from './init' + +export type SqlBuilder = { + with: string[] + select: string[] + from: string | undefined + join: string[] + leftJoin: string[] + where: string[] + orderBy: string[] + groupBy: string[] + limit: number | undefined + offset: number | undefined +} + +export type SqlParts = { + with?: string + select?: string + from?: string + join?: string + leftJoin?: string + groupBy?: string + where?: string + orderBy?: string + limit?: number + offset?: number +} + +type Args = (Args | SqlParts | SqlBuilder | undefined | false | 0 | '')[] + +export function buildSql(...parts: Args): SqlBuilder { + const definedParts = buildArray(parts) + + return { + with: definedParts.flatMap((part) => part.with || []), + select: definedParts.flatMap((part) => part.select || []), + from: last(definedParts.filter((part) => part.from))?.from, + join: definedParts.flatMap((part) => part.join || []), + leftJoin: definedParts.flatMap((part) => part.leftJoin || []), + where: definedParts.flatMap((part) => part.where || []), + orderBy: definedParts.flatMap((part) => part.orderBy || []), + groupBy: definedParts.flatMap((part) => part.groupBy || []), + limit: last(definedParts.filter((part) => part.limit))?.limit, + offset: last(definedParts.filter((part) => part.offset))?.offset, + } +} + +export function withClause(clause: string, formatValues?: any) { + const formattedWith = pgp.as.format(clause, formatValues) + return buildSql({ with: formattedWith }) +} + +export function select(clause: string) { + return buildSql({ select: clause }) +} + +export function from(clause: string, formatValues?: any) { + const from = pgp.as.format(clause, formatValues) + return buildSql({ from }) +} + +export function join(clause: string) { + return buildSql({ join: clause }) +} + +export function leftJoin(clause: string, formatValues?: any) { + const leftJoin = pgp.as.format(clause, formatValues) + return buildSql({ leftJoin }) +} + +export function groupBy(clause: string) { + return buildSql({ groupBy: clause }) +} + +export function where(clause: string, formatValues?: any) { + const where = pgp.as.format(clause, formatValues) + return buildSql({ where }) +} + +export function orderBy(clause: string, formatValues?: any) { + const orderBy = pgp.as.format(clause, formatValues) + return buildSql({ orderBy }) +} + +export function limit(limit: number, offset?: number) { + return buildSql({ limit, offset }) +} + +export function renderSql(...args: Args) { + const builder = buildSql(...args) + + const { + with: withClause, + select, + from, + join, + where, + orderBy, + limit, + offset, + leftJoin, + groupBy, + } = builder + return buildArray( + withClause.length && `with ${withClause.join(', ')}`, + select.length && `select ${select.join(', ')}`, + from && `from ${from}`, + join.length && `join ${join.join(' join ')}`, + leftJoin.length && `left join ${leftJoin.join(' left join ')}`, + where.length && + `where ${where.map((clause) => `(${clause})`).join(' and ')}`, + groupBy.length && `group by ${groupBy.join(', ')}`, + orderBy.length && `order by ${orderBy.join(', ')}`, + limit && `limit ${limit}`, + limit && offset && `offset ${offset}` + ).join('\n') +} diff --git a/backend/shared/src/supabase/users.ts b/backend/shared/src/supabase/users.ts new file mode 100644 index 0000000..99b5fd9 --- /dev/null +++ b/backend/shared/src/supabase/users.ts @@ -0,0 +1,27 @@ +import { User } from 'common/user' +import { SupabaseDirectClient } from 'shared/supabase/init' +import { + broadcastUpdatedPrivateUser, + broadcastUpdatedUser, +} from 'shared/websockets/helpers' +import { DataUpdate, updateData } from './utils' + +/** only updates data column. do not use for name, username */ +export const updateUser = async ( + db: SupabaseDirectClient, + id: string, + update: Partial +) => { + const fullUpdate = { id, ...update } + await updateData(db, 'users', 'id', fullUpdate) + broadcastUpdatedUser(fullUpdate) +} + +export const updatePrivateUser = async ( + db: SupabaseDirectClient, + id: string, + update: DataUpdate<'private_users'> +) => { + await updateData(db, 'private_users', 'id', { id, ...update }) + broadcastUpdatedPrivateUser(id) +} diff --git a/backend/shared/src/supabase/utils.ts b/backend/shared/src/supabase/utils.ts new file mode 100644 index 0000000..6927044 --- /dev/null +++ b/backend/shared/src/supabase/utils.ts @@ -0,0 +1,196 @@ +import { sortBy } from 'lodash' +import { pgp, SupabaseDirectClient } from './init' +import { DataFor, Tables, TableName, Column, Row } from 'common/supabase/utils' + +export async function insert< + T extends TableName, + ColumnValues extends Tables[T]['Insert'] +>(db: SupabaseDirectClient, table: T, values: ColumnValues) { + const columnNames = Object.keys(values) + const cs = new pgp.helpers.ColumnSet(columnNames, { table }) + const query = pgp.helpers.insert(values, cs) + // Hack to properly cast values. + const q = query.replace(/::(\w*)'/g, "'::$1") + return await db.one>(q + ` returning *`) +} + +export async function bulkInsert< + T extends TableName, + ColumnValues extends Tables[T]['Insert'] +>(db: SupabaseDirectClient, table: T, values: ColumnValues[]) { + if (values.length == 0) { + return [] + } + const columnNames = Object.keys(values[0]) + const cs = new pgp.helpers.ColumnSet(columnNames, { table }) + const query = pgp.helpers.insert(values, cs) + // Hack to properly cast values. + const q = query.replace(/::(\w*)'/g, "'::$1") + return await db.many>(q + ` returning *`) +} + +export async function update< + T extends TableName, + ColumnValues extends Tables[T]['Update'] +>( + db: SupabaseDirectClient, + table: T, + idField: Column, + values: ColumnValues +) { + const columnNames = Object.keys(values) + const cs = new pgp.helpers.ColumnSet(columnNames, { table }) + if (!(idField in values)) { + throw new Error(`missing ${idField} in values for ${columnNames}`) + } + const clause = pgp.as.format( + `${idField} = $1`, + values[idField as keyof ColumnValues] + ) + const query = pgp.helpers.update(values, cs) + ` WHERE ${clause}` + // Hack to properly cast values. + const q = query.replace(/::(\w*)'/g, "'::$1") + return await db.one>(q + ` returning *`) +} + +export async function bulkUpdate< + T extends TableName, + ColumnValues extends Tables[T]['Update'] +>( + db: SupabaseDirectClient, + table: T, + idFields: Column[], + values: ColumnValues[] +) { + if (values.length) { + const columnNames = Object.keys(values[0]) + const cs = new pgp.helpers.ColumnSet(columnNames, { table }) + const clause = idFields.map((f) => `v.${f} = t.${f}`).join(' and ') + const query = pgp.helpers.update(values, cs) + ` WHERE ${clause}` + // Hack to properly cast values. + const q = query.replace(/::(\w*)'/g, "'::$1") + await db.none(q) + } +} + +export async function bulkUpsert< + T extends TableName, + ColumnValues extends Tables[T]['Insert'], + Col extends Column +>( + db: SupabaseDirectClient, + table: T, + idField: Col | Col[], + values: ColumnValues[], + onConflict?: string +) { + if (!values.length) return + + const columnNames = Object.keys(values[0]) + const cs = new pgp.helpers.ColumnSet(columnNames, { table }) + const baseQuery = pgp.helpers.insert(values, cs) + // Hack to properly cast values. + const baseQueryReplaced = baseQuery.replace(/::(\w*)'/g, "'::$1") + + const primaryKey = Array.isArray(idField) ? idField.join(', ') : idField + const upsertAssigns = cs.assignColumns({ from: 'excluded', skip: idField }) + const query = + `${baseQueryReplaced} on ` + + (onConflict ? onConflict : `conflict(${primaryKey})`) + + ' ' + + (upsertAssigns ? `do update set ${upsertAssigns}` : `do nothing`) + + await db.none(query) +} + +// Replacement for BulkWriter +export async function bulkUpdateData( + db: SupabaseDirectClient, + table: T, + // TODO: explicit id field + updates: (Partial> & { id: string })[] +) { + if (updates.length > 0) { + const values = updates + .map( + ({ id, ...update }) => + `('${id}', '${JSON.stringify(update).replace("'", "''")}'::jsonb)` + ) + .join(',\n') + + await db.none( + `update ${table} as c + set data = data || v.update + from (values ${values}) as v(id, update) + where c.id = v.id` + ) + } +} + +// Replacement for firebase updateDoc. Updates just the data field (what firebase would've replicated to) +export async function updateData( + db: SupabaseDirectClient, + table: T, + idField: Column, + data: DataUpdate +) { + const { [idField]: id, ...rest } = data + if (!id) throw new Error(`Missing id field ${idField} in data`) + + const basic: Partial> = {} + const extras: string[] = [] + for (const key in rest) { + const val = rest[key as keyof typeof rest] + if (typeof val === 'function') { + extras.push(val(key)) + } else { + basic[key as keyof typeof rest] = val + } + } + const sortedExtraOperations = sortBy(extras, (statement) => + statement.startsWith('-') ? -1 : 1 + ) + + return await db.one>( + `update ${table} set data = data + ${sortedExtraOperations.join('\n')} + || $1 + where ${idField} = '${id}' returning *`, + [JSON.stringify(basic)] + ) +} + +/* + * this attempts to copy the firebase syntax + * each returns a function that takes the field name and returns a sql string that updateData can handle + */ +export const FieldVal = { + increment: (n: number) => (fieldName: string) => + `|| jsonb_build_object('${fieldName}', (data->'${fieldName}')::numeric + ${n})`, + + delete: () => (fieldName: string) => `- '${fieldName}'`, + + arrayConcat: + (...values: string[]) => + (fieldName: string) => { + return pgp.as.format( + `|| jsonb_build_object($1, coalesce(data->$1, '[]'::jsonb) || $2:json)`, + [fieldName, values] + ) + }, + + arrayRemove: + (...values: string[]) => + (fieldName: string) => { + return pgp.as.format( + `|| jsonb_build_object($1, coalesce(data->$1,'[]'::jsonb) - '{$2:raw}'::text[])`, + [fieldName, values.join(',')] + ) + }, +} + +type ValOrFieldVal> = { + [key in keyof R]?: R[key] | ((fieldName: string) => string) +} + +export type DataUpdate = ValOrFieldVal> diff --git a/backend/shared/src/utils.ts b/backend/shared/src/utils.ts new file mode 100644 index 0000000..24e5a5a --- /dev/null +++ b/backend/shared/src/utils.ts @@ -0,0 +1,69 @@ +import { + createSupabaseDirectClient, + SupabaseDirectClient, +} from 'shared/supabase/init' +import * as admin from 'firebase-admin' +import { convertPrivateUser, convertUser } from 'common/supabase/users' +import { log, type Logger } from 'shared/monitoring/log' +import { metrics } from 'shared/monitoring/metrics' + +export { metrics } +export { log, type Logger } + +export const getUser = async ( + userId: string, + pg: SupabaseDirectClient = createSupabaseDirectClient() +) => { + return await pg.oneOrNone( + `select * from users where id = $1 limit 1`, + [userId], + convertUser + ) +} + +export const getPrivateUser = async ( + userId: string, + pg: SupabaseDirectClient = createSupabaseDirectClient() +) => { + return await pg.oneOrNone( + `select * from private_users where id = $1 limit 1`, + [userId], + convertPrivateUser + ) +} + +export const getUserByUsername = async ( + username: string, + pg: SupabaseDirectClient = createSupabaseDirectClient() +) => { + const res = await pg.oneOrNone( + `select * from users where username = $1`, + username + ) + + return res ? convertUser(res) : null +} + +export const getPrivateUserByKey = async ( + apiKey: string, + pg: SupabaseDirectClient = createSupabaseDirectClient() +) => { + return await pg.oneOrNone( + `select * from private_users where data->>'apiKey' = $1 limit 1`, + [apiKey], + convertPrivateUser + ) +} + +// TODO: deprecate in favor of common/src/envs/is-prod.ts +export const isProd = () => { + // mqp: kind of hacky rn. the first clause is for cloud run API service, + // second clause is for local scripts and cloud functions + if (process.env.ENVIRONMENT) { + return process.env.ENVIRONMENT == 'PROD' + } else { + return admin.app().options.projectId === 'polylove' + } +} + +export const LOCAL_DEV = process.env.GOOGLE_CLOUD_PROJECT == null diff --git a/backend/shared/src/websockets/helpers.ts b/backend/shared/src/websockets/helpers.ts new file mode 100644 index 0000000..e4e5176 --- /dev/null +++ b/backend/shared/src/websockets/helpers.ts @@ -0,0 +1,16 @@ +import { broadcast } from './server' +import { type User } from 'common/user' +import { type Comment } from 'common/comment' + +export function broadcastUpdatedPrivateUser(userId: string) { + // don't send private user info because it's private and anyone can listen + broadcast(`private-user/${userId}`, {}) +} + +export function broadcastUpdatedUser(user: Partial & { id: string }) { + broadcast(`user/${user.id}`, { user }) +} + +export function broadcastUpdatedComment(comment: Comment) { + broadcast(`user/${comment.onUserId}/comment`, { comment }) +} diff --git a/backend/shared/src/websockets/server.ts b/backend/shared/src/websockets/server.ts new file mode 100644 index 0000000..b837b4e --- /dev/null +++ b/backend/shared/src/websockets/server.ts @@ -0,0 +1,168 @@ +import { Server as HttpServer } from 'node:http' +import { Server as WebSocketServer, RawData, WebSocket } from 'ws' +import { isError } from 'lodash' +import { LOCAL_DEV, log, metrics } from 'shared/utils' +import { Switchboard } from './switchboard' +import { + BroadcastPayload, + ClientMessage, + ServerMessage, + CLIENT_MESSAGE_SCHEMA, +} from 'common/api/websockets' + +const SWITCHBOARD = new Switchboard() + +// if a connection doesn't ping for this long, we assume the other side is toast +const CONNECTION_TIMEOUT_MS = 60 * 1000 + +export class MessageParseError extends Error { + details?: unknown + constructor(message: string, details?: unknown) { + super(message) + this.name = 'MessageParseError' + this.details = details + } +} + +function serializeError(err: unknown) { + return isError(err) ? err.message : 'Unexpected error.' +} + +function parseMessage(data: RawData): ClientMessage { + let messageObj: any + try { + messageObj = JSON.parse(data.toString()) + } catch (err) { + log.error(err) + throw new MessageParseError('Message was not valid UTF-8 encoded JSON.') + } + const result = CLIENT_MESSAGE_SCHEMA.safeParse(messageObj) + if (!result.success) { + const issues = result.error.issues.map((i) => { + return { + field: i.path.join('.') || null, + error: i.message, + } + }) + throw new MessageParseError('Error parsing message.', issues) + } else { + return result.data + } +} + +function processMessage(ws: WebSocket, data: RawData): ServerMessage<'ack'> { + try { + const msg = parseMessage(data) + const { type, txid } = msg + try { + switch (type) { + case 'identify': { + SWITCHBOARD.identify(ws, msg.uid) + break + } + case 'subscribe': { + SWITCHBOARD.subscribe(ws, ...msg.topics) + break + } + case 'unsubscribe': { + SWITCHBOARD.unsubscribe(ws, ...msg.topics) + break + } + case 'ping': { + SWITCHBOARD.markSeen(ws) + break + } + default: + throw new Error("Unknown message type; shouldn't be possible here.") + } + } catch (err) { + log.error(err) + return { type: 'ack', txid, success: false, error: serializeError(err) } + } + return { type: 'ack', txid, success: true } + } catch (err) { + log.error(err) + return { type: 'ack', success: false, error: serializeError(err) } + } +} + +export function broadcastMulti(topics: string[], data: BroadcastPayload) { + // ian: Don't await this: we don't need to hear back from all the clients and can take a dozen ms + const sendToSubscribers = (topic: string, msg: any) => { + const json = JSON.stringify(msg) + const subscribers = SWITCHBOARD.getSubscribers(topic) + return Promise.allSettled( + subscribers.map( + ([ws, _]) => + new Promise((resolve) => + ws.send(json, (err) => { + if (err) log.error('Broadcast error', { error: err }) + resolve() + }) + ) + ) + ).catch((err) => log.error('Broadcast failed', { error: err })) + } + + // it isn't secure to do this in prod for auth reasons (maybe?) + // but it's super convenient for testing + if (LOCAL_DEV) { + const msg = { type: 'broadcast', topic: '*', topics, data } + sendToSubscribers('*', msg) + } + + for (const topic of topics) { + const msg = { type: 'broadcast', topic, data } + sendToSubscribers(topic, msg) + metrics.inc('ws/broadcasts_sent', { topic }) + } +} + +export function broadcast(topic: string, data: BroadcastPayload) { + return broadcastMulti([topic], data) +} + +export function listen(server: HttpServer, path: string) { + const wss = new WebSocketServer({ server, path }) + let deadConnectionCleaner: NodeJS.Timeout | undefined + wss.on('listening', () => { + log.info(`Web socket server listening on ${path}.`) + deadConnectionCleaner = setInterval(function ping() { + const now = Date.now() + for (const ws of wss.clients) { + const lastSeen = SWITCHBOARD.getClient(ws).lastSeen + if (lastSeen < now - CONNECTION_TIMEOUT_MS) { + ws.terminate() + } + } + }, CONNECTION_TIMEOUT_MS) + }) + wss.on('error', (err) => { + log.error('Error on websocket server.', { error: err }) + }) + wss.on('connection', (ws) => { + // todo: should likely kill connections that haven't sent any ping for a long time + metrics.inc('ws/connections_established') + metrics.set('ws/open_connections', wss.clients.size) + log.debug('WS client connected.') + SWITCHBOARD.connect(ws) + ws.on('message', (data) => { + const result = processMessage(ws, data) + // mqp: check ws.readyState before sending? + ws.send(JSON.stringify(result)) + }) + ws.on('close', (code, reason) => { + metrics.inc('ws/connections_terminated') + metrics.set('ws/open_connections', wss.clients.size) + log.debug(`WS client disconnected.`, { code, reason: reason.toString() }) + SWITCHBOARD.disconnect(ws) + }) + ws.on('error', (err) => { + log.error('Error on websocket connection.', { error: err }) + }) + }) + wss.on('close', function close() { + clearInterval(deadConnectionCleaner) + }) + return wss +} diff --git a/backend/shared/src/websockets/switchboard.ts b/backend/shared/src/websockets/switchboard.ts new file mode 100644 index 0000000..58f3d8b --- /dev/null +++ b/backend/shared/src/websockets/switchboard.ts @@ -0,0 +1,65 @@ +import { WebSocket } from 'ws' + +export type ClientState = { + uid?: string + lastSeen: number + subscriptions: Set +} + +/** Tracks the relationship of clients to websockets and subscription lists. */ +export class Switchboard { + clients: Map + constructor() { + this.clients = new Map() + } + getClient(ws: WebSocket) { + const existing = this.clients.get(ws) + if (existing == null) { + throw new Error("Looking for a nonexistent client. Shouldn't happen.") + } + return existing + } + getAll() { + return this.clients.entries() + } + getSubscribers(topic: string) { + const entries = Array.from(this.clients.entries()) + return entries.filter(([_k, v]) => v.subscriptions.has(topic)) + } + connect(ws: WebSocket) { + const existing = this.clients.get(ws) + if (existing != null) { + throw new Error("Client already connected! Shouldn't happen.") + } + this.clients.set(ws, { lastSeen: Date.now(), subscriptions: new Set() }) + } + disconnect(ws: WebSocket) { + this.getClient(ws) + this.clients.delete(ws) + } + markSeen(ws: WebSocket) { + this.getClient(ws).lastSeen = Date.now() + } + identify(ws: WebSocket, uid: string) { + this.getClient(ws).uid = uid + this.markSeen(ws) + } + deidentify(ws: WebSocket) { + this.getClient(ws).uid = undefined + this.markSeen(ws) + } + subscribe(ws: WebSocket, ...topics: string[]) { + const client = this.getClient(ws) + for (const topic of topics) { + client.subscriptions.add(topic) + } + this.markSeen(ws) + } + unsubscribe(ws: WebSocket, ...topics: string[]) { + const client = this.getClient(ws) + for (const topic of topics) { + client.subscriptions.delete(topic) + } + this.markSeen(ws) + } +} diff --git a/backend/shared/tsconfig.json b/backend/shared/tsconfig.json new file mode 100644 index 0000000..bf38a58 --- /dev/null +++ b/backend/shared/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "rootDir": "src", + "composite": true, + "module": "commonjs", + "moduleResolution": "node", + "noImplicitReturns": true, + "outDir": "lib", + "tsBuildInfoFile": "lib/tsconfig.tsbuildinfo", + "sourceMap": true, + "strict": true, + "target": "esnext", + "esModuleInterop": true, + "jsx": "react-jsx", + "lib": ["esnext"], + "skipLibCheck": true, + "paths": { + "common/*": ["../../common/src/*", "../../../common/lib/*"], + "shared/*": ["./src/*"] + } + }, + "references": [{ "path": "../../common" }], + "include": ["src/**/*.ts", "src/**/*.tsx"] +} diff --git a/backend/supabase/firebase.sql b/backend/supabase/firebase.sql new file mode 100644 index 0000000..48e1c86 --- /dev/null +++ b/backend/supabase/firebase.sql @@ -0,0 +1,4 @@ +create +or replace function public.firebase_uid () returns text language sql stable parallel SAFE as $function$ +select nullif(current_setting('request.jwt.claims', true)::json->>'sub', '')::text; +$function$; \ No newline at end of file diff --git a/backend/supabase/functions.sql b/backend/supabase/functions.sql new file mode 100644 index 0000000..1b5f3dd --- /dev/null +++ b/backend/supabase/functions.sql @@ -0,0 +1,113 @@ +-- This file is autogenerated from regen-schema.ts + +create +or replace function public.to_jsonb (jsonb) returns jsonb language sql immutable parallel SAFE strict as $function$ select $1 $function$; + +create +or replace function public.ts_to_millis (ts timestamp without time zone) returns bigint language sql immutable parallel SAFE as $function$ +select extract(epoch from ts)::bigint * 1000 +$function$; + +create +or replace function public.ts_to_millis (ts timestamp with time zone) returns bigint language sql immutable parallel SAFE as $function$ +select (extract(epoch from ts) * 1000)::bigint +$function$; + +create +or replace function public.millis_to_ts (millis bigint) returns timestamp with time zone language sql immutable parallel SAFE as $function$ +select to_timestamp(millis / 1000.0) + $function$; + +create +or replace function public.millis_interval (start_millis bigint, end_millis bigint) returns interval language sql immutable parallel SAFE as $function$ +select millis_to_ts(end_millis) - millis_to_ts(start_millis) + $function$; + +create +or replace function public.calculate_earth_distance_km ( + lat1 double precision, + lon1 double precision, + lat2 double precision, + lon2 double precision +) returns double precision language plpgsql immutable as $function$ +DECLARE + radius_earth_km CONSTANT FLOAT := 6371; + delta_lat FLOAT; + delta_lon FLOAT; + a FLOAT; + c FLOAT; +BEGIN + -- Convert degrees to radians + lat1 := RADIANS(lat1); + lon1 := RADIANS(lon1); + lat2 := RADIANS(lat2); + lon2 := RADIANS(lon2); + + -- Calculate differences + delta_lat := lat2 - lat1; + delta_lon := lon2 - lon1; + + -- Apply Haversine formula + a := SIN(delta_lat / 2) ^ 2 + COS(lat1) * COS(lat2) * SIN(delta_lon / 2) ^ 2; + c := 2 * ATAN2(SQRT(a), SQRT(1 - a)); + + -- Calculate distance + RETURN radius_earth_km * c; +END; +$function$; + +create +or replace function public.can_access_private_messages (channel_id bigint, user_id text) returns boolean language sql parallel SAFE as $function$ +select exists ( + select 1 from private_user_message_channel_members + where private_user_message_channel_members.channel_id = $1 + and private_user_message_channel_members.user_id = $2 +) +$function$; + + + +create +or replace function public.get_average_rating (user_id text) returns numeric language plpgsql as $function$ +DECLARE + result numeric; +BEGIN + SELECT AVG(rating)::numeric INTO result + FROM reviews + WHERE vendor_id = user_id; + RETURN result; +END; +$function$; + +create +or replace function public.is_admin () returns boolean language plpgsql as $function$ +begin + return false; +end; +$function$; + +create +or replace function public.is_admin (user_id text) returns boolean language plpgsql as $function$ +begin + return false; +end; +$function$; + +create +or replace function public.random_alphanumeric (length integer) returns text language plpgsql as $function$ +DECLARE + result TEXT; +BEGIN + WITH alphanum AS ( + SELECT ARRAY['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', + 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', + 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z'] AS chars + ) + SELECT array_to_string(ARRAY ( + SELECT alphanum.chars[1 + floor(random() * 62)::integer] + FROM alphanum, generate_series(1, length) + ), '') INTO result; + + RETURN result; +END; +$function$; diff --git a/backend/supabase/functions_others.sql b/backend/supabase/functions_others.sql new file mode 100644 index 0000000..9ef10db --- /dev/null +++ b/backend/supabase/functions_others.sql @@ -0,0 +1,46 @@ + + +create +or replace function public.get_compatibility_questions_with_answer_count () returns setof record language plpgsql as $function$ +BEGIN +RETURN QUERY +SELECT + love_questions.*, + COUNT(love_compatibility_answers.question_id) as answer_count +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 + answer_count DESC; +END; +$function$; + +create +or replace function public.get_love_question_answers_and_lovers (p_question_id bigint) returns setof record language plpgsql as $function$ +BEGIN +RETURN QUERY +SELECT + love_answers.question_id, + love_answers.created_time, + love_answers.free_response, + love_answers.multiple_choice, + love_answers.integer, + lovers.age, + lovers.gender, + lovers.city, + users.data +FROM + lovers + JOIN + love_answers ON lovers.user_id = love_answers.creator_id + join + users on lovers.user_id = users.id +WHERE + love_answers.question_id = p_question_id +order by love_answers.created_time desc; +END; +$function$; \ No newline at end of file diff --git a/backend/supabase/love_answers.sql b/backend/supabase/love_answers.sql new file mode 100644 index 0000000..060b1ee --- /dev/null +++ b/backend/supabase/love_answers.sql @@ -0,0 +1,39 @@ +-- This file is autogenerated from regen-schema.ts +CREATE TABLE IF NOT EXISTS love_answers ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + created_time TIMESTAMPTZ DEFAULT now() NOT NULL, + creator_id TEXT NOT NULL, + free_response TEXT, + integer INTEGER, + multiple_choice INTEGER, + question_id BIGINT NOT NULL + ); + +-- Row Level Security +ALTER TABLE love_answers ENABLE ROW LEVEL SECURITY; + +-- Policies +DROP POLICY IF EXISTS "public read" ON love_answers; +CREATE POLICY "public read" ON love_answers FOR SELECT USING (true); + +DROP POLICY IF EXISTS "self delete" ON love_answers; +CREATE POLICY "self delete" ON love_answers FOR DELETE USING (creator_id = firebase_uid()); + +DROP POLICY IF EXISTS "self insert" ON love_answers; +CREATE POLICY "self insert" ON love_answers FOR INSERT WITH CHECK (creator_id = firebase_uid()); + +DROP POLICY IF EXISTS "self update" ON love_answers; +CREATE POLICY "self update" ON love_answers FOR UPDATE USING (creator_id = firebase_uid()); + +-- Indexes +DROP INDEX IF EXISTS love_answers_creator_id_created_time_idx; +CREATE INDEX IF NOT EXISTS love_answers_creator_id_created_time_idx + ON public.love_answers USING btree (creator_id, created_time DESC); + +DROP INDEX IF EXISTS love_answers_question_creator_unique; +CREATE UNIQUE INDEX IF NOT EXISTS love_answers_question_creator_unique + ON public.love_answers USING btree (question_id, creator_id); + +DROP INDEX IF EXISTS love_answers_question_id_idx; +CREATE INDEX IF NOT EXISTS love_answers_question_id_idx + ON public.love_answers USING btree (question_id); diff --git a/backend/supabase/love_compatibility_answers.sql b/backend/supabase/love_compatibility_answers.sql new file mode 100644 index 0000000..0cbda67 --- /dev/null +++ b/backend/supabase/love_compatibility_answers.sql @@ -0,0 +1,46 @@ +-- This file is autogenerated from regen-schema.ts +CREATE TABLE IF NOT EXISTS love_compatibility_answers ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + created_time TIMESTAMPTZ DEFAULT now() NOT NULL, + creator_id TEXT NOT NULL, + explanation TEXT, + importance INTEGER NOT NULL, + multiple_choice INTEGER NOT NULL, + pref_choices INTEGER[] NOT NULL, + question_id BIGINT NOT NULL +); + +-- Row Level Security +ALTER TABLE love_compatibility_answers ENABLE ROW LEVEL SECURITY; + +ALTER TABLE love_compatibility_answers + ADD CONSTRAINT unique_question_creator + UNIQUE (question_id, creator_id); + + +-- Policies +DROP POLICY IF EXISTS "public read" ON love_compatibility_answers; +CREATE POLICY "public read" ON love_compatibility_answers + FOR SELECT USING (true); + +DROP POLICY IF EXISTS "self delete" ON love_compatibility_answers; +CREATE POLICY "self delete" ON love_compatibility_answers + FOR DELETE USING (creator_id = firebase_uid()); + +DROP POLICY IF EXISTS "self insert" ON love_compatibility_answers; +CREATE POLICY "self insert" ON love_compatibility_answers + FOR INSERT WITH CHECK (creator_id = firebase_uid()); + +DROP POLICY IF EXISTS "self update" ON love_compatibility_answers; +CREATE POLICY "self update" ON love_compatibility_answers + FOR UPDATE USING (creator_id = firebase_uid()); + +-- Indexes +CREATE INDEX IF NOT EXISTS love_compatibility_answers_creator_id_created_time_idx + ON public.love_compatibility_answers (creator_id, created_time DESC); + +CREATE UNIQUE INDEX IF NOT EXISTS love_compatibility_answers_question_creator_unique + ON public.love_compatibility_answers (question_id, creator_id); + +CREATE INDEX IF NOT EXISTS love_compatibility_answers_question_id_idx + ON public.love_compatibility_answers (question_id); diff --git a/backend/supabase/love_likes.sql b/backend/supabase/love_likes.sql new file mode 100644 index 0000000..42ee85d --- /dev/null +++ b/backend/supabase/love_likes.sql @@ -0,0 +1,23 @@ +-- This file is autogenerated from regen-schema.ts +CREATE TABLE IF NOT EXISTS love_likes ( + created_time TIMESTAMPTZ DEFAULT now() NOT NULL, + creator_id TEXT NOT NULL, + like_id TEXT DEFAULT random_alphanumeric(12) NOT NULL, + target_id TEXT NOT NULL, + CONSTRAINT love_likes_pkey PRIMARY KEY (creator_id, like_id) +); + +-- Row Level Security +ALTER TABLE love_likes ENABLE ROW LEVEL SECURITY; + +-- Policies +DROP POLICY IF EXISTS "public read" ON love_likes; +CREATE POLICY "public read" ON love_likes + FOR SELECT USING (true); + +-- Indexes +-- The primary key already creates a unique index on (creator_id, like_id) +-- so we do not recreate that. Additional indexes: + +CREATE INDEX IF NOT EXISTS user_likes_target_id_raw + ON public.love_likes (target_id); diff --git a/backend/supabase/love_questions.sql b/backend/supabase/love_questions.sql new file mode 100644 index 0000000..fc7452a --- /dev/null +++ b/backend/supabase/love_questions.sql @@ -0,0 +1,22 @@ +-- This file is autogenerated from regen-schema.ts +CREATE TABLE IF NOT EXISTS love_questions ( + answer_type TEXT DEFAULT 'free_response' NOT NULL, + created_time TIMESTAMPTZ DEFAULT now() NOT NULL, + creator_id TEXT NOT NULL, + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + importance_score NUMERIC DEFAULT 0 NOT NULL, + multiple_choice_options JSONB, + question TEXT NOT NULL +); + +-- Row Level Security +ALTER TABLE love_questions ENABLE ROW LEVEL SECURITY; + +-- Policies +DROP POLICY IF EXISTS "public read" ON love_questions; +CREATE POLICY "public read" ON love_questions +FOR ALL USING (true); + +-- Indexes +-- The primary key automatically creates a unique index on (id), +-- so the explicit index on id is redundant and removed. diff --git a/backend/supabase/love_ships.sql b/backend/supabase/love_ships.sql new file mode 100644 index 0000000..89c62bb --- /dev/null +++ b/backend/supabase/love_ships.sql @@ -0,0 +1,26 @@ +-- This file is autogenerated from regen-schema.ts +CREATE TABLE IF NOT EXISTS love_ships ( + created_time TIMESTAMPTZ DEFAULT now() NOT NULL, + creator_id TEXT NOT NULL, + ship_id TEXT DEFAULT random_alphanumeric(12) NOT NULL, + target1_id TEXT NOT NULL, + target2_id TEXT NOT NULL, + CONSTRAINT love_ships_pkey PRIMARY KEY (creator_id, ship_id) +); + +-- Row Level Security +ALTER TABLE love_ships ENABLE ROW LEVEL SECURITY; + +-- Policies +DROP POLICY IF EXISTS "public read" ON love_ships; +CREATE POLICY "public read" ON love_ships +FOR SELECT USING (true); + +-- Indexes +-- Primary key automatically creates a unique index on (creator_id, ship_id), so no need to recreate it. +-- Keep additional indexes for query optimization: +DROP INDEX IF EXISTS love_ships_target1_id; +CREATE INDEX love_ships_target1_id ON public.love_ships USING btree (target1_id); + +DROP INDEX IF EXISTS love_ships_target2_id; +CREATE INDEX love_ships_target2_id ON public.love_ships USING btree (target2_id); diff --git a/backend/supabase/love_stars.sql b/backend/supabase/love_stars.sql new file mode 100644 index 0000000..e6c55f8 --- /dev/null +++ b/backend/supabase/love_stars.sql @@ -0,0 +1,22 @@ +-- This file is autogenerated from regen-schema.ts +CREATE TABLE IF NOT EXISTS love_stars ( + created_time TIMESTAMPTZ DEFAULT now() NOT NULL, + creator_id TEXT NOT NULL, + star_id TEXT DEFAULT random_alphanumeric(12) NOT NULL, + target_id TEXT NOT NULL, + CONSTRAINT love_stars_pkey PRIMARY KEY (creator_id, star_id) +); + +-- Row Level Security +ALTER TABLE love_stars ENABLE ROW LEVEL SECURITY; + +-- Policies +DROP POLICY IF EXISTS "public read" ON love_stars; +CREATE POLICY "public read" ON love_stars +FOR SELECT USING (true); + +-- Indexes +-- The primary key already creates a unique index on (creator_id, star_id), so no need to recreate it. + +DROP INDEX IF EXISTS love_stars_target_id_idx; +CREATE INDEX love_stars_target_id_idx ON public.love_stars USING btree (target_id); diff --git a/backend/supabase/love_waitlist.sql b/backend/supabase/love_waitlist.sql new file mode 100644 index 0000000..8a2dfc3 --- /dev/null +++ b/backend/supabase/love_waitlist.sql @@ -0,0 +1,17 @@ +-- This file is autogenerated from regen-schema.ts +CREATE TABLE IF NOT EXISTS love_waitlist ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + created_time TIMESTAMPTZ DEFAULT now() NOT NULL, + email TEXT NOT NULL +); + +-- Row Level Security +ALTER TABLE love_waitlist ENABLE ROW LEVEL SECURITY; + +-- Policies +DROP POLICY IF EXISTS "anon insert" ON love_waitlist; +CREATE POLICY "anon insert" ON love_waitlist + FOR INSERT WITH CHECK (true); + +-- Indexes +-- Primary key automatically creates a unique index on id, so no additional index is needed. diff --git a/backend/supabase/lover_comments.sql b/backend/supabase/lover_comments.sql new file mode 100644 index 0000000..a858788 --- /dev/null +++ b/backend/supabase/lover_comments.sql @@ -0,0 +1,24 @@ +-- This file is autogenerated from regen-schema.ts +CREATE TABLE IF NOT EXISTS lover_comments ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + content JSONB NOT NULL, + created_time TIMESTAMPTZ DEFAULT now() NOT NULL, + hidden BOOLEAN DEFAULT false NOT NULL, + on_user_id TEXT NOT NULL, + reply_to_comment_id BIGINT, + user_avatar_url TEXT NOT NULL, + user_id TEXT NOT NULL, + user_name TEXT NOT NULL, + user_username TEXT NOT NULL +); + +-- Row Level Security +ALTER TABLE lover_comments ENABLE ROW LEVEL SECURITY; + +-- Policies +DROP POLICY IF EXISTS "public read" ON lover_comments; +CREATE POLICY "public read" ON lover_comments FOR ALL USING (true); + +-- Indexes +CREATE INDEX IF NOT EXISTS lover_comments_user_id_idx + ON public.lover_comments USING btree (on_user_id); diff --git a/backend/supabase/lovers.sql b/backend/supabase/lovers.sql new file mode 100644 index 0000000..1194782 --- /dev/null +++ b/backend/supabase/lovers.sql @@ -0,0 +1,79 @@ +-- This file is autogenerated from regen-schema.ts + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'lover_visibility') THEN +CREATE TYPE lover_visibility AS ENUM ('public', 'member'); +END IF; +END$$; + +CREATE TABLE IF NOT EXISTS lovers ( + age INTEGER DEFAULT 18 NOT NULL, + bio JSON, + born_in_location TEXT, + city TEXT NOT NULL, + city_latitude NUMERIC(9, 6), + city_longitude NUMERIC(9, 6), + comments_enabled BOOLEAN DEFAULT TRUE NOT NULL, + company TEXT, + country TEXT, + created_time TIMESTAMPTZ DEFAULT now() NOT NULL, + drinks_per_month INTEGER, + education_level TEXT, + ethnicity TEXT[], + gender TEXT NOT NULL, + geodb_city_id TEXT, + has_kids INTEGER, + height_in_inches INTEGER, + id BIGINT GENERATED ALWAYS AS IDENTITY NOT NULL, + is_smoker BOOLEAN, + is_vegetarian_or_vegan BOOLEAN, + last_online_time TIMESTAMPTZ DEFAULT now() NOT NULL, + looking_for_matches BOOLEAN DEFAULT TRUE NOT NULL, + messaging_status TEXT DEFAULT 'open'::TEXT NOT NULL, + occupation TEXT, + occupation_title TEXT, + photo_urls TEXT[], + pinned_url TEXT, + political_beliefs TEXT[], + pref_age_max INTEGER DEFAULT 100 NOT NULL, + pref_age_min INTEGER DEFAULT 18 NOT NULL, + pref_gender TEXT[] NOT NULL, + pref_relation_styles TEXT[] NOT NULL, + referred_by_username TEXT, + region_code TEXT, + religious_belief_strength INTEGER, + religious_beliefs TEXT, + twitter TEXT, + university TEXT, + user_id TEXT NOT NULL, + visibility lover_visibility DEFAULT 'member'::lover_visibility NOT NULL, + wants_kids_strength INTEGER DEFAULT 0 NOT NULL, + website TEXT, + CONSTRAINT lovers_pkey PRIMARY KEY (id) + ); + +-- Row Level Security +ALTER TABLE lovers ENABLE ROW LEVEL SECURITY; + +-- Policies +DROP POLICY IF EXISTS "public read" ON lovers; + +CREATE POLICY "public read" ON lovers +FOR SELECT + USING (true); + +DROP POLICY IF EXISTS "self update" ON lovers; + +CREATE POLICY "self update" ON lovers +FOR UPDATE + WITH CHECK ((user_id = firebase_uid())); + +-- Indexes +DROP INDEX IF EXISTS lovers_user_id_idx; + +CREATE INDEX lovers_user_id_idx ON public.lovers USING btree (user_id); + +DROP INDEX IF EXISTS unique_user_id; + +CREATE UNIQUE INDEX unique_user_id ON public.lovers USING btree (user_id); diff --git a/backend/supabase/makefile b/backend/supabase/makefile new file mode 100644 index 0000000..98dbc24 --- /dev/null +++ b/backend/supabase/makefile @@ -0,0 +1,18 @@ +# usage: make [command] +help: + @cat ./makefile + +regen-types: regen-types-prod + +regen-types-prod: + npx supabase gen types typescript --project-id ltzepxnhhnrnvovqblfr --schema public > ../../common/src/supabase/schema.ts + cd ../../common && npx prettier --write ./src/supabase/schema.ts + +regen-types-dev: + npx supabase gen types typescript --project-id ltzepxnhhnrnvovqblfr --schema public > ../../common/src/supabase/schema.ts + cd ../../common && npx prettier --write ./src/supabase/schema.ts + +regen-schema: + cd ../scripts && npx ts-node regen-schema.ts + +# regen-schema-dev: use-dev regen-schema diff --git a/backend/supabase/migrations/20250410042701_add_report.sql b/backend/supabase/migrations/20250410042701_add_report.sql new file mode 100644 index 0000000..8b21437 --- /dev/null +++ b/backend/supabase/migrations/20250410042701_add_report.sql @@ -0,0 +1,23 @@ +-- This file is copied from https://github.com/manifoldmarkets/manifold/blob/main/backend/supabase/reports.sql +create table if not exists + reports ( + content_id text not null, + content_owner_id text not null, + content_type text not null, + created_time timestamp with time zone default now(), + description text, + id text default uuid_generate_v4 () not null, + parent_id text, + parent_type text, + user_id text not null + ); + +-- Foreign Keys +alter table reports +add constraint reports_content_owner_id_fkey foreign key (content_owner_id) references users (id); + +alter table reports +add constraint reports_user_id_fkey foreign key (user_id) references users (id); + +-- Row Level Security +alter table reports enable row level security; diff --git a/backend/supabase/private_user_message_channel_members.sql b/backend/supabase/private_user_message_channel_members.sql new file mode 100644 index 0000000..323cc0a --- /dev/null +++ b/backend/supabase/private_user_message_channel_members.sql @@ -0,0 +1,42 @@ +-- This file is autogenerated from regen-schema.ts +CREATE TABLE IF NOT EXISTS private_user_message_channel_members ( + channel_id BIGINT NOT NULL, + created_time TIMESTAMPTZ DEFAULT now() NOT NULL, + id BIGINT GENERATED ALWAYS AS IDENTITY NOT NULL, + notify_after_time TIMESTAMPTZ DEFAULT now() NOT NULL, + role TEXT DEFAULT 'member'::TEXT NOT NULL, + status TEXT DEFAULT 'proposed'::TEXT NOT NULL, + user_id TEXT NOT NULL, + CONSTRAINT private_user_message_channel_members_pkey PRIMARY KEY (id) + ); + +-- Foreign Keys +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'channel_id_fkey' + AND conrelid = 'private_user_message_channel_members'::regclass + ) THEN + ALTER TABLE private_user_message_channel_members + ADD CONSTRAINT channel_id_fkey + FOREIGN KEY (channel_id) + REFERENCES private_user_message_channels (id) + ON UPDATE CASCADE ON DELETE CASCADE; + END IF; +END$$; + +-- Row Level Security +ALTER TABLE private_user_message_channel_members ENABLE ROW LEVEL SECURITY; + +-- Indexes +DROP INDEX IF EXISTS pumcm_members_idx; +CREATE INDEX pumcm_members_idx + ON public.private_user_message_channel_members + USING btree (channel_id, user_id); + +DROP INDEX IF EXISTS unique_user_channel; +CREATE UNIQUE INDEX unique_user_channel + ON public.private_user_message_channel_members + USING btree (channel_id, user_id); diff --git a/backend/supabase/private_user_message_channels.sql b/backend/supabase/private_user_message_channels.sql new file mode 100644 index 0000000..c1a51e0 --- /dev/null +++ b/backend/supabase/private_user_message_channels.sql @@ -0,0 +1,21 @@ +-- This file is autogenerated from regen-schema.ts +CREATE TABLE IF NOT EXISTS private_user_message_channels ( + created_time TIMESTAMPTZ DEFAULT now() NOT NULL, + id BIGINT GENERATED ALWAYS AS IDENTITY NOT NULL, + last_updated_time TIMESTAMPTZ DEFAULT now() NOT NULL, + title TEXT, + CONSTRAINT private_user_message_channels_pkey PRIMARY KEY (id) +); + +-- Row Level Security +ALTER TABLE private_user_message_channels ENABLE ROW LEVEL SECURITY; + +-- Policies +DROP POLICY IF EXISTS "public read" ON private_user_message_channels; + +CREATE POLICY "public read" ON private_user_message_channels + FOR ALL + USING (true); + +-- Indexes +-- Removed redundant primary key index creation because PRIMARY KEY already creates a unique index on id diff --git a/backend/supabase/private_user_messages.sql b/backend/supabase/private_user_messages.sql new file mode 100644 index 0000000..bb4e802 --- /dev/null +++ b/backend/supabase/private_user_messages.sql @@ -0,0 +1,23 @@ +-- This file is autogenerated from regen-schema.ts +CREATE TABLE IF NOT EXISTS private_user_messages ( + channel_id BIGINT NOT NULL, + content JSONB NOT NULL, + created_time TIMESTAMPTZ DEFAULT now() NOT NULL, + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + old_id BIGINT, + user_id TEXT NOT NULL, + visibility TEXT DEFAULT 'private'::TEXT NOT NULL, + CONSTRAINT private_user_messages_channel_id_fkey + FOREIGN KEY (channel_id) + REFERENCES private_user_message_channels (id) + ON UPDATE CASCADE ON DELETE CASCADE + ); + +-- Row Level Security +ALTER TABLE private_user_messages ENABLE ROW LEVEL SECURITY; + +-- Indexes +DROP INDEX IF EXISTS private_user_messages_channel_id_idx; + +CREATE INDEX IF NOT EXISTS private_user_messages_channel_id_idx + ON public.private_user_messages USING btree (channel_id, created_time DESC); diff --git a/backend/supabase/private_user_seen_message_channels.sql b/backend/supabase/private_user_seen_message_channels.sql new file mode 100644 index 0000000..4988184 --- /dev/null +++ b/backend/supabase/private_user_seen_message_channels.sql @@ -0,0 +1,55 @@ +-- This file is autogenerated from regen-schema.ts +CREATE TABLE IF NOT EXISTS private_user_seen_message_channels ( + channel_id BIGINT NOT NULL, + created_time TIMESTAMPTZ DEFAULT now() NOT NULL, + id BIGINT GENERATED ALWAYS AS IDENTITY NOT NULL, + user_id TEXT NOT NULL, + CONSTRAINT private_user_seen_message_channels_pkey PRIMARY KEY (id) + ); + +-- Foreign Keys +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'channel_id_fkey' + AND conrelid = 'private_user_seen_message_channels'::regclass + ) THEN +ALTER TABLE private_user_seen_message_channels + ADD CONSTRAINT channel_id_fkey + FOREIGN KEY (channel_id) + REFERENCES private_user_message_channels (id) + ON UPDATE CASCADE ON DELETE CASCADE; +END IF; +END$$; + + +-- Row Level Security +ALTER TABLE private_user_seen_message_channels ENABLE ROW LEVEL SECURITY; + +-- Policies +DROP POLICY IF EXISTS "private member insert" ON private_user_seen_message_channels; + +CREATE POLICY "private member insert" ON private_user_seen_message_channels +FOR INSERT +WITH CHECK ( + (firebase_uid() IS NOT NULL) + AND can_access_private_messages(channel_id, firebase_uid()) +); + +DROP POLICY IF EXISTS "private member read" ON private_user_seen_message_channels; + +CREATE POLICY "private member read" ON private_user_seen_message_channels +FOR SELECT + USING ( + (firebase_uid() IS NOT NULL) + AND can_access_private_messages(channel_id, firebase_uid()) + ); + +-- Indexes +DROP INDEX IF EXISTS user_seen_private_messages_created_time_desc_idx; + +CREATE INDEX user_seen_private_messages_created_time_desc_idx + ON public.private_user_seen_message_channels + USING btree (user_id, channel_id, created_time DESC); diff --git a/backend/supabase/private_users.sql b/backend/supabase/private_users.sql new file mode 100644 index 0000000..89a7186 --- /dev/null +++ b/backend/supabase/private_users.sql @@ -0,0 +1,19 @@ +-- This file is autogenerated from regen-schema.ts +CREATE TABLE IF NOT EXISTS private_users ( + data JSONB NOT NULL, + id TEXT NOT NULL, + CONSTRAINT private_users_pkey PRIMARY KEY (id) +); + +-- Row Level Security +ALTER TABLE private_users ENABLE ROW LEVEL SECURITY; + +-- Policies +DROP POLICY IF EXISTS "private read" ON private_users; + +CREATE POLICY "private read" ON private_users + FOR SELECT + USING ((firebase_uid() = id)); + +-- Indexes +-- Removed redundant index creation because PRIMARY KEY already creates a unique index on id diff --git a/backend/supabase/reports.sql b/backend/supabase/reports.sql new file mode 100644 index 0000000..4819728 --- /dev/null +++ b/backend/supabase/reports.sql @@ -0,0 +1,23 @@ +-- This file is autogenerated from regen-schema.ts +create table if not exists + reports ( + content_id text not null, + content_owner_id text not null, + content_type text not null, + created_time timestamp with time zone default now(), + description text, + id text default uuid_generate_v4 () not null, + parent_id text, + parent_type text, + user_id text not null + ); + +-- Foreign Keys +alter table reports +add constraint reports_content_owner_id_fkey foreign key (content_owner_id) references users (id); + +alter table reports +add constraint reports_user_id_fkey foreign key (user_id) references users (id); + +-- Row Level Security +alter table reports enable row level security; diff --git a/backend/supabase/temp_users.sql b/backend/supabase/temp_users.sql new file mode 100644 index 0000000..7bb23cb --- /dev/null +++ b/backend/supabase/temp_users.sql @@ -0,0 +1,13 @@ +-- This file is autogenerated from regen-schema.ts +create table if not exists + temp_users ( + created_time timestamp with time zone, + id text, + name text, + private_user_data jsonb, + user_data jsonb, + username text + ); + +-- Row Level Security +alter table temp_users enable row level security; diff --git a/backend/supabase/user_events.sql b/backend/supabase/user_events.sql new file mode 100644 index 0000000..679ad41 --- /dev/null +++ b/backend/supabase/user_events.sql @@ -0,0 +1,36 @@ +-- This file is autogenerated from regen-schema.ts +CREATE TABLE IF NOT EXISTS user_events ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + ad_id TEXT, + comment_id TEXT, + contract_id TEXT, + data JSONB NOT NULL, + name TEXT NOT NULL, + ts TIMESTAMPTZ DEFAULT now() NOT NULL, + user_id TEXT +); + +-- Row Level Security +ALTER TABLE user_events ENABLE ROW LEVEL SECURITY; + +-- Policies +DROP POLICY IF EXISTS "self and admin read" ON user_events; +CREATE POLICY "self and admin read" ON user_events +FOR SELECT + USING ((user_id = firebase_uid()) OR is_admin(firebase_uid())); + +DROP POLICY IF EXISTS "user can insert" ON user_events; +CREATE POLICY "user can insert" ON user_events +FOR INSERT +WITH CHECK (true); + +DROP POLICY IF EXISTS "anyone can insert" ON user_events; +create policy "anyone can insert" on user_events +for insert +to anon +with check (true); + + +-- Indexes +-- Primary key automatically creates a unique index on (id) +CREATE INDEX IF NOT EXISTS user_events_ts ON public.user_events (ts DESC); diff --git a/backend/supabase/user_notifications.sql b/backend/supabase/user_notifications.sql new file mode 100644 index 0000000..472ed1b --- /dev/null +++ b/backend/supabase/user_notifications.sql @@ -0,0 +1,24 @@ +-- This file is autogenerated from regen-schema.ts +CREATE TABLE IF NOT EXISTS user_notifications ( + notification_id TEXT NOT NULL, + user_id TEXT NOT NULL, + data JSONB NOT NULL, + CONSTRAINT user_notifications_pkey PRIMARY KEY (notification_id, user_id) +); + +-- Row Level Security +ALTER TABLE user_notifications ENABLE ROW LEVEL SECURITY; + +-- Policies +DROP POLICY IF EXISTS "public read" ON user_notifications; +CREATE POLICY "public read" ON user_notifications + FOR SELECT USING (true); + +-- Indexes +-- The primary key already creates a unique index on (notification_id, user_id) + +CREATE INDEX IF NOT EXISTS user_notifications_user_id_created_time + ON public.user_notifications ( + user_id, + ((data ->> 'createdTime')::BIGINT) DESC + ); diff --git a/backend/supabase/users.sql b/backend/supabase/users.sql new file mode 100644 index 0000000..60806a2 --- /dev/null +++ b/backend/supabase/users.sql @@ -0,0 +1,39 @@ +-- This file is autogenerated from regen-schema.ts +CREATE TABLE IF NOT EXISTS users ( + created_time TIMESTAMPTZ DEFAULT now() NOT NULL, + data JSONB NOT NULL, + id TEXT DEFAULT random_alphanumeric(12) NOT NULL, + name TEXT NOT NULL, + name_username_vector tsvector GENERATED ALWAYS AS ( + to_tsvector( + 'english'::regconfig, + (name || ' '::text) || username + ) + ) STORED, + username TEXT NOT NULL, + CONSTRAINT users_pkey PRIMARY KEY (id) + ); + +-- Row Level Security +ALTER TABLE users ENABLE ROW LEVEL SECURITY; + +-- Policies +DROP POLICY IF EXISTS "public read" ON users; + +CREATE POLICY "public read" ON users +FOR SELECT + USING (true); + +-- Indexes +DROP INDEX IF EXISTS user_username_idx; +CREATE INDEX user_username_idx ON public.users USING btree (username); + +DROP INDEX IF EXISTS users_created_time; +CREATE INDEX users_created_time ON public.users USING btree (created_time DESC); + +DROP INDEX IF EXISTS users_name_idx; +CREATE INDEX users_name_idx ON public.users USING btree (name); + +-- Remove these if you trust PRIMARY KEY auto-index: +-- DROP INDEX IF EXISTS users_pkey; +-- CREATE UNIQUE INDEX users_pkey ON public.users USING btree (id); diff --git a/backend/supabase/views.sql b/backend/supabase/views.sql new file mode 100644 index 0000000..375638d --- /dev/null +++ b/backend/supabase/views.sql @@ -0,0 +1 @@ +-- This file is autogenerated from regen-schema.ts diff --git a/common/.gitignore b/common/.gitignore new file mode 100644 index 0000000..1132085 --- /dev/null +++ b/common/.gitignore @@ -0,0 +1,12 @@ +# Compiled JavaScript files +lib/ + +# TypeScript v1 declaration files +typings/ + +# Node.js dependency directory +node_modules/ + +package-lock.json +ui-debug.log +firebase-debug.log diff --git a/common/jest.config.js b/common/jest.config.js new file mode 100644 index 0000000..22b1bda --- /dev/null +++ b/common/jest.config.js @@ -0,0 +1,12 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +const { pathsToModuleNameMapper } = require('ts-jest') +const { compilerOptions } = require('./tsconfig') + +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { + prefix: '/', + }), + testMatch: ['**/*.test.ts'], +} diff --git a/common/package.json b/common/package.json new file mode 100644 index 0000000..b17f1df --- /dev/null +++ b/common/package.json @@ -0,0 +1,35 @@ +{ + "name": "common", + "version": "1.0.0", + "private": true, + "scripts": { + "build": "tsc -b && tsc-alias", + "compile": "tsc -b", + "verify": "yarn --cwd=.. verify", + "verify:dir": "npx eslint . --max-warnings 0", + "test": "jest" + }, + "sideEffects": false, + "dependencies": { + "@supabase/supabase-js": "2.38.5", + "@tiptap/core": "2.3.2", + "@tiptap/extension-image": "2.3.2", + "@tiptap/extension-link": "2.3.2", + "@tiptap/extension-mention": "2.3.2", + "@tiptap/pm": "2.3.2", + "@tiptap/starter-kit": "2.3.2", + "@tiptap/suggestion": "2.3.2", + "dayjs": "1.11.4", + "lodash": "4.17.21", + "string-similarity": "4.0.4", + "zod": "3.21.4" + }, + "devDependencies": { + "@types/jest": "29.2.4", + "@types/lodash": "4.14.178", + "@types/string-similarity": "4.0.0", + "jest": "29.3.1", + "supabase": "2.15.8", + "ts-jest": "29.0.3" + } +} diff --git a/common/src/api/love-types.ts b/common/src/api/love-types.ts new file mode 100644 index 0000000..befb459 --- /dev/null +++ b/common/src/api/love-types.ts @@ -0,0 +1,11 @@ +export type LikeData = { + user_id: string + created_time: number +} +export type ShipData = { + creator_id: string + target1_id: string + target2_id: string + target_id: string + created_time: number +} diff --git a/common/src/api/schema.ts b/common/src/api/schema.ts new file mode 100644 index 0000000..d0374b0 --- /dev/null +++ b/common/src/api/schema.ts @@ -0,0 +1,497 @@ +import { + contentSchema, + combinedLoveUsersSchema, + baseLoversSchema, + arraybeSchema, +} from 'common/api/zod-types' +import { PrivateChatMessage } from 'common/chat-message' +import { CompatibilityScore } from 'common/love/compatibility-score' +import { MAX_COMPATIBILITY_QUESTION_LENGTH } from 'common/love/constants' +import { Lover, LoverRow } from 'common/love/lover' +import { Row } from 'common/supabase/utils' +import { PrivateUser, User } from 'common/user' +import { z } from 'zod' +import { LikeData, ShipData } from './love-types' +import { DisplayUser, FullUser } from './user-types' +import { PrivateMessageChannel } from 'common/supabase/private-messages' +import { Notification } from 'common/notifications' +import { arrify } from 'common/util/array' +import { notification_preference } from 'common/user-notification-preferences' + +// mqp: very unscientific, just balancing our willingness to accept load +// with user willingness to put up with stale data +export const DEFAULT_CACHE_STRATEGY = + 'public, max-age=5, stale-while-revalidate=10' + +type APIGenericSchema = { + // GET is for retrieval, POST is to mutate something, PUT is idempotent mutation (can be repeated safely) + method: 'GET' | 'POST' | 'PUT' + // whether the endpoint requires authentication + authed: boolean + // zod schema for the request body (or for params for GET requests) + props: z.ZodType + // note this has to be JSON serializable + returns?: Record + // Cache-Control header. like, 'max-age=60' + cache?: string +} + +let _apiTypeCheck: { [x: string]: APIGenericSchema } + +export const API = (_apiTypeCheck = { + health: { + method: 'GET', + authed: false, + props: z.object({}), + returns: {} as { message: 'Server is working.'; uid?: string }, + }, + 'get-supabase-token': { + method: 'GET', + authed: true, + props: z.object({}), + returns: {} as { jwt: string }, + }, + 'mark-all-notifs-read': { + method: 'POST', + authed: true, + props: z.object({}), + }, + 'user/by-id/:id/block': { + method: 'POST', + authed: true, + props: z.object({ id: z.string() }).strict(), + }, + 'user/by-id/:id/unblock': { + method: 'POST', + authed: true, + props: z.object({ id: z.string() }).strict(), + }, + 'ban-user': { + method: 'POST', + authed: true, + props: z + .object({ + userId: z.string(), + unban: z.boolean().optional(), + }) + .strict(), + }, + 'create-user': { + // TODO rest + method: 'POST', + authed: true, + returns: {} as { user: User; privateUser: PrivateUser }, + props: z + .object({ + deviceToken: z.string().optional(), + adminToken: z.string().optional(), + }) + .strict(), + }, + 'create-lover': { + method: 'POST', + authed: true, + returns: {} as Row<'lovers'>, + props: baseLoversSchema, + }, + report: { + method: 'POST', + authed: true, + props: z + .object({ + contentOwnerId: z.string(), + contentType: z.enum(['user', 'comment', 'contract']), + contentId: z.string(), + description: z.string().optional(), + parentId: z.string().optional(), + parentType: z.enum(['contract', 'post', 'user']).optional(), + }) + .strict(), + returns: {} as any, + }, + me: { + method: 'GET', + authed: true, + cache: DEFAULT_CACHE_STRATEGY, + props: z.object({}), + returns: {} as FullUser, + }, + 'me/update': { + method: 'POST', + authed: true, + props: z.object({ + name: z.string().trim().min(1).optional(), + username: z.string().trim().min(1).optional(), + avatarUrl: z.string().optional(), + bio: z.string().optional(), + link: z.record(z.string().nullable()).optional(), + // settings + optOutBetWarnings: z.boolean().optional(), + isAdvancedTrader: z.boolean().optional(), + //internal + shouldShowWelcome: z.boolean().optional(), + hasSeenContractFollowModal: z.boolean().optional(), + hasSeenLoanModal: z.boolean().optional(), + + // Legacy fields (deprecated) + /** @deprecated Use links.site instead */ + website: z.string().optional(), + /** @deprecated Use links.x instead */ + twitterHandle: z.string().optional(), + /** @deprecated Use links.discord instead */ + discordHandle: z.string().optional(), + }), + returns: {} as FullUser, + }, + 'update-lover': { + method: 'POST', + authed: true, + props: combinedLoveUsersSchema.partial(), + returns: {} as LoverRow, + }, + 'update-notif-settings': { + method: 'POST', + authed: true, + props: z.object({ + type: z.string() as z.ZodType, + medium: z.enum(['email', 'browser', 'mobile']), + enabled: z.boolean(), + }), + }, + 'me/delete': { + method: 'POST', + authed: true, + props: z.object({ + username: z.string(), // just so you're sure + }), + }, + 'me/private': { + method: 'GET', + authed: true, + props: z.object({}), + returns: {} as PrivateUser, + }, + 'user/:username': { + method: 'GET', + authed: false, + cache: DEFAULT_CACHE_STRATEGY, + returns: {} as FullUser, + props: z.object({ username: z.string() }).strict(), + }, + 'user/:username/lite': { + method: 'GET', + authed: false, + cache: DEFAULT_CACHE_STRATEGY, + returns: {} as DisplayUser, + props: z.object({ username: z.string() }).strict(), + }, + 'user/by-id/:id': { + method: 'GET', + authed: false, + cache: DEFAULT_CACHE_STRATEGY, + returns: {} as FullUser, + props: z.object({ id: z.string() }).strict(), + }, + 'user/by-id/:id/lite': { + method: 'GET', + authed: false, + cache: DEFAULT_CACHE_STRATEGY, + returns: {} as DisplayUser, + props: z.object({ id: z.string() }).strict(), + }, + 'search-users': { + method: 'GET', + authed: false, + cache: DEFAULT_CACHE_STRATEGY, + returns: [] as FullUser[], + props: z + .object({ + term: z.string(), + limit: z.coerce.number().gte(0).lte(1000).default(500), + page: z.coerce.number().gte(0).default(0), + }) + .strict(), + }, + 'compatible-lovers': { + method: 'GET', + authed: false, + props: z.object({ userId: z.string() }), + returns: {} as { + lover: Lover + compatibleLovers: Lover[] + loverCompatibilityScores: { + [userId: string]: CompatibilityScore + } + }, + }, + 'remove-pinned-photo': { + method: 'POST', + authed: true, + returns: { success: true }, + props: z + .object({ + userId: z.string(), + }) + .strict(), + }, + 'get-compatibility-questions': { + method: 'GET', + authed: false, + props: z.object({}), + returns: {} as { + status: 'success' + questions: (Row<'love_questions'> & { + answer_count: number + score: number + })[] + }, + }, + 'like-lover': { + method: 'POST', + authed: true, + props: z.object({ + targetUserId: z.string(), + remove: z.boolean().optional(), + }), + returns: {} as { + status: 'success' + }, + }, + 'ship-lovers': { + method: 'POST', + authed: true, + props: z.object({ + targetUserId1: z.string(), + targetUserId2: z.string(), + remove: z.boolean().optional(), + }), + returns: {} as { + status: 'success' + }, + }, + 'get-likes-and-ships': { + method: 'GET', + authed: false, + props: z + .object({ + userId: z.string(), + }) + .strict(), + returns: {} as { + status: 'success' + likesReceived: LikeData[] + likesGiven: LikeData[] + ships: ShipData[] + }, + }, + 'has-free-like': { + method: 'GET', + authed: true, + props: z.object({}).strict(), + returns: {} as { + status: 'success' + hasFreeLike: boolean + }, + }, + 'star-lover': { + method: 'POST', + authed: true, + props: z.object({ + targetUserId: z.string(), + remove: z.boolean().optional(), + }), + returns: {} as { + status: 'success' + }, + }, + 'get-lovers': { + method: 'GET', + authed: false, + props: z + .object({ + limit: z.coerce.number().optional().default(20), + after: z.string().optional(), + // Search and filter parameters + name: z.string().optional(), + genders: arraybeSchema.optional(), + pref_gender: arraybeSchema.optional(), + pref_age_min: z.coerce.number().optional(), + pref_age_max: z.coerce.number().optional(), + pref_relation_styles: arraybeSchema.optional(), + wants_kids_strength: z.coerce.number().optional(), + has_kids: z.coerce.number().optional(), + is_smoker: z.coerce.boolean().optional(), + geodbCityIds: arraybeSchema.optional(), + compatibleWithUserId: z.string().optional(), + orderBy: z + .enum(['last_online_time', 'created_time', 'compatibility_score']) + .optional() + .default('last_online_time'), + }) + .strict(), + returns: {} as { + status: 'success' | 'fail' + lovers: Lover[] + }, + }, + 'get-lover-answers': { + method: 'GET', + authed: false, + props: z.object({ userId: z.string() }).strict(), + returns: {} as { + status: 'success' + answers: Row<'love_compatibility_answers'>[] + }, + }, + 'create-comment': { + method: 'POST', + authed: true, + props: z.object({ + userId: z.string(), + content: contentSchema, + replyToCommentId: z.string().optional(), + }), + returns: {} as any, + }, + 'hide-comment': { + method: 'POST', + authed: true, + props: z.object({ + commentId: z.string(), + hide: z.boolean(), + }), + returns: {} as any, + }, + 'get-channel-memberships': { + method: 'GET', + authed: true, + props: z.object({ + channelId: z.coerce.number().optional(), + createdTime: z.string().optional(), + lastUpdatedTime: z.string().optional(), + limit: z.coerce.number(), + }), + returns: { + channels: [] as PrivateMessageChannel[], + memberIdsByChannelId: {} as { [channelId: string]: string[] }, + }, + }, + 'get-channel-messages': { + method: 'GET', + authed: true, + props: z.object({ + channelId: z.coerce.number(), + limit: z.coerce.number(), + id: z.coerce.number().optional(), + }), + returns: [] as PrivateChatMessage[], + }, + 'get-channel-seen-time': { + method: 'GET', + authed: true, + props: z.object({ + channelIds: z + .array(z.coerce.number()) + .or(z.coerce.number()) + .transform(arrify), + }), + returns: [] as [number, string][], + }, + 'set-channel-seen-time': { + method: 'POST', + authed: true, + props: z.object({ + channelId: z.coerce.number(), + }), + }, + 'get-notifications': { + method: 'GET', + authed: true, + returns: [] as Notification[], + props: z + .object({ + after: z.coerce.number().optional(), + limit: z.coerce.number().gte(0).lte(1000).default(100), + }) + .strict(), + }, + 'create-private-user-message': { + method: 'POST', + authed: true, + returns: {} as any, + props: z.object({ + content: contentSchema, + channelId: z.number(), + }), + }, + 'create-private-user-message-channel': { + method: 'POST', + authed: true, + returns: {} as any, + props: z.object({ + userIds: z.array(z.string()), + }), + }, + 'update-private-user-message-channel': { + method: 'POST', + authed: true, + returns: {} as any, + props: z.object({ + channelId: z.number(), + notifyAfterTime: z.number(), + }), + }, + 'leave-private-user-message-channel': { + method: 'POST', + authed: true, + returns: {} as any, + props: z.object({ + channelId: z.number(), + }), + }, + 'create-compatibility-question': { + method: 'POST', + authed: true, + returns: {} as any, + props: z.object({ + question: z.string().min(1).max(MAX_COMPATIBILITY_QUESTION_LENGTH), + options: z.record(z.string(), z.number()), + }), + }, + 'search-location': { + method: 'POST', + authed: false, + returns: {} as any, + props: z.object({ + term: z.string(), + limit: z.number().optional(), + }), + }, + 'search-near-city': { + method: 'POST', + authed: false, + returns: {} as any, + props: z.object({ + cityId: z.string(), + radius: z.number().min(1).max(500), + }), + }, +} as const) + +export type APIPath = keyof typeof API +export type APISchema = (typeof API)[N] + +export type APIParams = z.input['props']> +export type ValidatedAPIParams = z.output< + APISchema['props'] +> + +export type APIResponse = APISchema extends { + returns: Record +} + ? APISchema['returns'] + : void + +export type APIResponseOptionalContinue = + | { continue: () => Promise; result: APIResponse } + | APIResponse diff --git a/common/src/api/user-types.ts b/common/src/api/user-types.ts new file mode 100644 index 0000000..5ff07ec --- /dev/null +++ b/common/src/api/user-types.ts @@ -0,0 +1,27 @@ +import { ENV_CONFIG, MOD_IDS } from 'common/envs/constants' +import { User } from 'common/user' +import { removeUndefinedProps } from 'common/util/object' + +export type DisplayUser = { + id: string + name: string + username: string + avatarUrl: string + isBannedFromPosting?: boolean +} + +export type FullUser = User & { + url: string + isBot?: boolean + isAdmin?: boolean + isTrustworthy?: boolean +} + +export function toUserAPIResponse(user: User): FullUser { + return removeUndefinedProps({ + ...user, + url: `https://${ENV_CONFIG.domain}/${user.username}`, + isAdmin: ENV_CONFIG.adminIds.includes(user.id), + isTrustworthy: MOD_IDS.includes(user.id), + }) +} diff --git a/common/src/api/utils.ts b/common/src/api/utils.ts new file mode 100644 index 0000000..9e45063 --- /dev/null +++ b/common/src/api/utils.ts @@ -0,0 +1,39 @@ +import { ENV_CONFIG } from 'common/envs/constants' +import { type APIPath } from './schema' + +type ErrorCode = + | 400 // your input is bad (like zod is mad) + | 401 // you aren't logged in / your account doesn't exist + | 403 // you aren't allowed to do it + | 404 // we can't find it + | 429 // you're too much for us + | 500 // we fucked up + +export class APIError extends Error { + code: ErrorCode + details?: unknown + constructor(code: ErrorCode, message: string, details?: unknown) { + super(message) + this.code = code + this.name = 'APIError' + this.details = details + } +} + +export function pathWithPrefix(path: APIPath) { + return `v0/${path}` +} + +export function getWebsocketUrl() { + const endpoint = process.env.NEXT_PUBLIC_API_URL ?? ENV_CONFIG.apiEndpoint + const protocol = endpoint.startsWith('localhost') ? 'ws' : 'wss' + + return `${protocol}://${endpoint}/ws` +} + +export function getApiUrl(path: string) { + const endpoint = process.env.NEXT_PUBLIC_API_URL ?? ENV_CONFIG.apiEndpoint + const protocol = endpoint.startsWith('localhost') ? 'http' : 'https' + const prefix = 'v0' + return `${protocol}://${endpoint}/${prefix}/${path}` +} diff --git a/common/src/api/websocket-client.ts b/common/src/api/websocket-client.ts new file mode 100644 index 0000000..74ee83e --- /dev/null +++ b/common/src/api/websocket-client.ts @@ -0,0 +1,231 @@ +import { ClientMessage, ClientMessageType, ServerMessage } from './websockets' + +// mqp: useful for debugging +const VERBOSE_LOGGING = false + +// mqp: no way should our server ever take 5 seconds to reply +const TIMEOUT_MS = 5000 + +const RECONNECT_WAIT_MS = 5000 + +type ConnectingState = typeof WebSocket.CONNECTING +type OpenState = typeof WebSocket.OPEN +type ClosingState = typeof WebSocket.CLOSING +type ClosedState = typeof WebSocket.CLOSED + +export type ReadyState = + | OpenState + | ConnectingState + | ClosedState + | ClosingState + +export function formatState(state: ReadyState) { + switch (state) { + case WebSocket.CONNECTING: + return 'connecting' + case WebSocket.OPEN: + return 'open' + case WebSocket.CLOSING: + return 'closing' + case WebSocket.CLOSED: + return 'closed' + default: + throw new Error('Invalid websocket state.') + } +} + +export type BroadcastHandler = (msg: ServerMessage<'broadcast'>) => void + +type OutstandingTxn = { + resolve: () => void + reject: (err: Error) => void + timeout?: NodeJS.Timeout +} + +/** Client for the API websocket realtime server. Automatically manages reconnection + * and resubscription on disconnect, and allows subscribers to get a callback + * when something is broadcasted. */ +export class APIRealtimeClient { + ws!: WebSocket + url: string + txid: number + // all txns that are in flight, with no ack/error/timeout + txns: Map + // subscribers by the topic they are subscribed to + subscriptions: Map + connectTimeout?: NodeJS.Timeout + heartbeat?: NodeJS.Timeout + + constructor(url: string) { + this.url = url + this.txid = 0 + this.txns = new Map() + this.subscriptions = new Map() + this.connect() + } + + get state() { + return this.ws.readyState as ReadyState + } + + close() { + this.ws.close(1000, 'Closed manually.') + clearTimeout(this.connectTimeout) + } + + connect() { + // you may wish to refer to https://websockets.spec.whatwg.org/ + // in order to check the semantics of events etc. + this.ws = new WebSocket(this.url) + this.ws.onmessage = (ev) => { + this.receiveMessage(JSON.parse(ev.data)) + } + this.ws.onerror = (ev) => { + console.error('API websocket error: ', ev) + // this can fire without an onclose if this is the first time we ever try + // to connect, so we need to turn on our reconnect in that case + this.waitAndReconnect() + } + this.ws.onopen = (_ev) => { + if (VERBOSE_LOGGING) { + console.info('API websocket opened.') + } + this.heartbeat = setInterval( + async () => this.sendMessage('ping', {}).catch(console.error), + 30000 + ) + if (this.subscriptions.size > 0) { + this.sendMessage('subscribe', { + topics: Array.from(this.subscriptions.keys()), + }).catch(console.error) + } + } + this.ws.onclose = (ev) => { + // note that if the connection closes due to an error, onerror fires and then this + if (VERBOSE_LOGGING) { + console.info(`API websocket closed with code=${ev.code}: ${ev.reason}`) + } + clearInterval(this.heartbeat) + + // mqp: we might need to change how the txn stuff works if we ever want to + // implement "wait until i am subscribed, and then do something" in a component. + // right now it cannot be reliably used to detect that in the presence of reconnects + for (const txn of Array.from(this.txns.values())) { + clearTimeout(txn.timeout) + txn.reject(new Error('Websocket was closed.')) + } + this.txns.clear() + + // 1000 is RFC code for normal on-purpose closure + if (ev.code !== 1000) { + this.waitAndReconnect() + } + } + } + + waitAndReconnect() { + if (this.connectTimeout == null) { + this.connectTimeout = setTimeout(() => { + this.connectTimeout = undefined + this.connect() + }, RECONNECT_WAIT_MS) + } + } + + receiveMessage(msg: ServerMessage) { + if (VERBOSE_LOGGING) { + console.info('< Incoming API websocket message: ', msg) + } + switch (msg.type) { + case 'broadcast': { + const handlers = this.subscriptions.get(msg.topic) + if (handlers == null) { + // it's not exceptional for a message to come in with no handlers -- + // maybe the server didn't get our unsubscribe yet + return + } + for (const handler of handlers) { + handler(msg) + } + return + } + case 'ack': { + if (msg.txid != null) { + const txn = this.txns.get(msg.txid) + if (txn == null) { + // mqp: only reason this should happen is getting an ack after timeout + console.warn(`Websocket message with old txid=${msg.txid}.`) + } else { + clearTimeout(txn.timeout) + if (msg.error != null) { + txn.reject(new Error(msg.error)) + } else { + txn.resolve() + } + this.txns.delete(msg.txid) + } + } + return + } + default: + console.warn(`Unknown API websocket message type received: ${msg}`) + } + } + + async sendMessage( + type: T, + data: Omit, 'type' | 'txid'> + ) { + if (VERBOSE_LOGGING) { + console.info(`> Outgoing API websocket ${type} message: `, data) + } + if (this.state === WebSocket.OPEN) { + return new Promise((resolve, reject) => { + const txid = this.txid++ + const timeout = setTimeout(() => { + this.txns.delete(txid) + reject(new Error(`Websocket message with txid ${txid} timed out.`)) + }, TIMEOUT_MS) + this.txns.set(txid, { resolve, reject, timeout }) + this.ws.send(JSON.stringify({ type, txid, ...data })) + }) + } else { + // expected if components in the code try to subscribe or unsubscribe + // while the socket is closed -- in this case we expect to get the state + // fixed up in the websocket onopen handler when we reconnect + } + } + + async identify(uid: string) { + return await this.sendMessage('identify', { uid }) + } + + async subscribe(topics: string[], handler: BroadcastHandler) { + for (const topic of topics) { + let existingHandlers = this.subscriptions.get(topic) + if (existingHandlers == null) { + this.subscriptions.set(topic, (existingHandlers = [handler])) + return await this.sendMessage('subscribe', { topics: [topic] }) + } else { + existingHandlers.push(handler) + } + } + } + + async unsubscribe(topics: string[], handler: BroadcastHandler) { + for (const topic of topics) { + const existingHandlers = this.subscriptions.get(topic) + if (existingHandlers == null) { + console.error(`Subscription mapping busted -- ${topic} handlers null.`) + } else { + const remainingHandlers = existingHandlers.filter((h) => h != handler) + if (remainingHandlers.length > 0) { + this.subscriptions.set(topic, remainingHandlers) + } else { + this.subscriptions.delete(topic) + return await this.sendMessage('unsubscribe', { topics: [topic] }) + } + } + } + } +} diff --git a/common/src/api/websockets.ts b/common/src/api/websockets.ts new file mode 100644 index 0000000..662a891 --- /dev/null +++ b/common/src/api/websockets.ts @@ -0,0 +1,65 @@ +import { z } from 'zod' + +export const CLIENT_MESSAGE_SCHEMAS = { + identify: z.object({ + type: z.literal('identify'), + txid: z.number(), + uid: z.string(), + }), + subscribe: z.object({ + type: z.literal('subscribe'), + txid: z.number(), + topics: z.array(z.string()), + }), + unsubscribe: z.object({ + type: z.literal('unsubscribe'), + txid: z.number(), + topics: z.array(z.string()), + }), + ping: z.object({ + type: z.literal('ping'), + txid: z.number(), + }), +} as const + +export const CLIENT_MESSAGE_SCHEMA = z.union([ + CLIENT_MESSAGE_SCHEMAS.identify, + CLIENT_MESSAGE_SCHEMAS.subscribe, + CLIENT_MESSAGE_SCHEMAS.unsubscribe, + CLIENT_MESSAGE_SCHEMAS.ping, +]) + +// mqp: it may be not unusual for clients to subscribe to topics in ways such that they +// receive multiple messages at the same time if there is something like a new bet. +// in that case i'm not sure if it would be worthwhile to implement a message format +// so that multiple broadcasts can come down in a single websocket frame. for now, +// default to simplicity + +export const SERVER_MESSAGE_SCHEMAS = { + ack: z.object({ + type: z.literal('ack'), + txid: z.number().optional(), + success: z.boolean(), + error: z.string().optional(), + }), + broadcast: z.object({ + type: z.literal('broadcast'), + topic: z.string(), + data: z.record(z.unknown()), + }), +} + +export const SERVER_MESSAGE_SCHEMA = z.union([ + SERVER_MESSAGE_SCHEMAS.ack, + SERVER_MESSAGE_SCHEMAS.broadcast, +]) + +export type ClientMessageType = keyof typeof CLIENT_MESSAGE_SCHEMAS +export type ClientMessage = + z.infer<(typeof CLIENT_MESSAGE_SCHEMAS)[T]> + +export type ServerMessageType = keyof typeof SERVER_MESSAGE_SCHEMAS +export type ServerMessage = + z.infer<(typeof SERVER_MESSAGE_SCHEMAS)[T]> + +export type BroadcastPayload = ServerMessage<'broadcast'>['data'] diff --git a/common/src/api/zod-types.ts b/common/src/api/zod-types.ts new file mode 100644 index 0000000..c3cab69 --- /dev/null +++ b/common/src/api/zod-types.ts @@ -0,0 +1,102 @@ +import { z } from 'zod' +import { type JSONContent } from '@tiptap/core' +import { arrify } from 'common/util/array' + +/* GET request array can be like ?a=1 or ?a=1&a=2 */ +export const arraybeSchema = z + .array(z.string()) + .or(z.string()) + .transform(arrify) + +// @ts-ignore +export const contentSchema: z.ZodType = z.lazy(() => + z.intersection( + z.record(z.any()), + z.object({ + type: z.string().optional(), + attrs: z.record(z.any()).optional(), + content: z.array(contentSchema).optional(), + marks: z + .array( + z.intersection( + z.record(z.any()), + z.object({ + type: z.string(), + attrs: z.record(z.any()).optional(), + }) + ) + ) + .optional(), + text: z.string().optional(), + }) + ) +) + +const genderType = z.string() +// z.union([ +// z.literal('male'), +// z.literal('female'), +// z.literal('trans-female'), +// z.literal('trans-male'), +// z.literal('non-binary'), +// ]) +const genderTypes = z.array(genderType) + +export const baseLoversSchema = z.object({ + // Required fields + age: z.number().min(18).max(100), + gender: genderType, + pref_gender: genderTypes, + pref_age_min: z.number().min(18).max(999), + pref_age_max: z.number().min(18).max(1000), + pref_relation_styles: z.array( + z.union([ + z.literal('mono'), + z.literal('poly'), + z.literal('open'), + z.literal('other'), + ]) + ), + wants_kids_strength: z.number().min(0), + looking_for_matches: z.boolean(), + photo_urls: z.array(z.string()), + visibility: z.union([z.literal('public'), z.literal('member')]), + + geodb_city_id: z.string().optional(), + city: z.string(), + region_code: z.string().optional(), + country: z.string().optional(), + city_latitude: z.number().optional(), + city_longitude: z.number().optional(), + + pinned_url: z.string(), + referred_by_username: z.string().optional(), +}) + +const optionalLoversSchema = z.object({ + political_beliefs: z.array(z.string()).optional(), + religious_belief_strength: z.number().optional(), + religious_beliefs: z.string().optional(), + ethnicity: z.array(z.string()).optional(), + born_in_location: z.string().optional(), + height_in_inches: z.number().optional(), + has_pets: z.boolean().optional(), + education_level: z.string().optional(), + last_online_time: z.string().optional(), + is_smoker: z.boolean().optional(), + drinks_per_month: z.number().min(0).optional(), + is_vegetarian_or_vegan: z.boolean().optional(), + has_kids: z.number().min(0).optional(), + university: z.string().optional(), + occupation_title: z.string().optional(), + occupation: z.string().optional(), + company: z.string().optional(), + comments_enabled: z.boolean().optional(), + website: z.string().optional(), + bio: contentSchema.optional().nullable(), + twitter: z.string().optional(), + avatar_url: z.string().optional(), +}) + +export const combinedLoveUsersSchema = + baseLoversSchema.merge(optionalLoversSchema) diff --git a/common/src/chat-message.ts b/common/src/chat-message.ts new file mode 100644 index 0000000..c5783f4 --- /dev/null +++ b/common/src/chat-message.ts @@ -0,0 +1,15 @@ +import { type JSONContent } from '@tiptap/core' +export type ChatVisibility = 'private' | 'system_status' | 'introduction' + +export type ChatMessage = { + id: string + userId: string + channelId: string + content: JSONContent + createdTime: number + visibility: ChatVisibility +} +export type PrivateChatMessage = Omit & { + id: number + createdTimeTs: string +} diff --git a/common/src/comment.ts b/common/src/comment.ts new file mode 100644 index 0000000..d2cbdac --- /dev/null +++ b/common/src/comment.ts @@ -0,0 +1,39 @@ +import { type JSONContent } from '@tiptap/core' + +export const MAX_COMMENT_LENGTH = 10000 + +type Visibility = 'public' | 'unlisted' | 'private' + +// Currently, comments are created after the bet, not atomically with the bet. +// They're uniquely identified by the pair contractId/betId. +export type Comment = { + id: string + replyToCommentId?: string + userId: string + + // lover + commentType: 'lover' + onUserId: string + + /** @deprecated - content now stored as JSON in content*/ + text?: string + content: JSONContent + createdTime: number + + // Denormalized, for rendering comments + userName: string + userUsername: string + userAvatarUrl?: string + + hidden?: boolean + hiddenTime?: number + hiderId?: string + pinned?: boolean + pinnedTime?: number + pinnerId?: string + visibility: Visibility + editedTime?: number + isApi?: boolean +} + +export type ReplyToUserInfo = { id: string; username: string } diff --git a/common/src/edge/og.ts b/common/src/edge/og.ts new file mode 100644 index 0000000..7ab8089 --- /dev/null +++ b/common/src/edge/og.ts @@ -0,0 +1,16 @@ +// see https://vercel.com/docs/concepts/functions/edge-functions/edge-functions-api for restrictions + +export type Point = { x: number; y: number } + +export function base64toPoints(base64urlString: string) { + const b64 = base64urlString.replace(/-/g, '+').replace(/_/g, '/') + const bin = atob(b64) + const u = Uint8Array.from(bin, (c) => c.charCodeAt(0)) + const f = new Float32Array(u.buffer) + + const points = [] as { x: number; y: number }[] + for (let i = 0; i < f.length; i += 2) { + points.push({ x: f[i], y: f[i + 1] }) + } + return points +} diff --git a/common/src/envs/constants.ts b/common/src/envs/constants.ts new file mode 100644 index 0000000..89e4bbd --- /dev/null +++ b/common/src/envs/constants.ts @@ -0,0 +1,124 @@ +import { DEV_CONFIG } from './dev' +import { EnvConfig, PROD_CONFIG } from './prod' + +// Valid in web client & Vercel deployments only. +export const ENV = (process.env.NEXT_PUBLIC_FIREBASE_ENV ?? 'PROD') as + | 'PROD' + | 'DEV' + +export const CONFIGS: { [env: string]: EnvConfig } = { + PROD: PROD_CONFIG, + DEV: DEV_CONFIG, +} + +export const MAX_DESCRIPTION_LENGTH = 16000 +export const MAX_ANSWER_LENGTH = 240 + +export const ENV_CONFIG = CONFIGS[ENV] + +export function isAdminId(id: string) { + return ENV_CONFIG.adminIds.includes(id) +} + +export function isModId(id: string) { + return MOD_IDS.includes(id) +} +export const DOMAIN = ENV_CONFIG.domain +export const FIREBASE_CONFIG = ENV_CONFIG.firebaseConfig +export const PROJECT_ID = ENV_CONFIG.firebaseConfig.projectId + +export const AUTH_COOKIE_NAME = `FBUSER_${PROJECT_ID.toUpperCase().replace( + /-/g, + '_' +)}` + +export const MOD_IDS = [ + 'HTbxWFlzWGeHUTiwZvvF0qm8W433', // Conflux + '9dAaZrNSx5OT0su6rpusDoG9WPN2', // dglid + '5XMvQhA3YgcTzyoJRiNqGWyuB9k2', // dreev + '2VhlvfTaRqZbFn2jqxk2Am9jgsE2', // Gabrielle + 'XeQf3ygmrGM1MxdsE3JSlmq8vL42', // Jacy + 'JlVpsgzLsbOUT4pajswVMr0ZzmM2', // Joshua + 'sA7V30Ic73XZtniboy2eKr6ekkn1', // MartinRandall + 'jO7sUhIDTQbAJ3w86akzncTlpRG2', // MichaelWheatley + 'lkkqZxiWCpOgtJ9ztJcAKz4d9y33', // NathanpmYoung + 'YOILpFNyg0gGj79zBIBUpJigHQ83', // SneakySly + 'KHX2ThSFtLQlau58hrjtCX7OL2h2', // shankypanky (stefanie) +] + +export const VERIFIED_USERNAMES = [ + 'ScottAlexander', + 'Aella', + 'Roko', + 'KatjaGrace', + 'patrissimo', +] + +export const TEN_YEARS_SECS = 60 * 60 * 24 * 365 * 10 + +export const RESERVED_PATHS = [ + '_next', + 'about', + 'ad', + 'add-funds', + 'ads', + 'admin', + 'analytics', + 'api', + 'browse', + 'career', + 'careers', + 'chat', + 'chats', + 'common', + 'contact', + 'contacts', + 'create', + 'dashboard', + 'discord', + 'embed', + 'facebook', + 'find', + 'github', + 'google', + 'group', + 'groups', + 'help', + 'home', + 'link', + 'linkAccount', + 'links', + 'live', + 'login', + 'manifest', + 'manifold', + 'market', + 'markets', + 'message', + 'messages', + 'notifications', + 'og-test', + 'payments', + 'privacy', + 'profile', + 'public', + 'questions', + 'referral', + 'referrals', + 'send', + 'server-sitemap', + 'sign-in', + 'sign-in-waiting', + 'sitemap', + 'slack', + 'stats', + 'styles', + 'team', + 'terms', + 'twitch', + 'twitter', + 'user', + 'users', + 'web', + 'welcome', +] diff --git a/common/src/envs/dev.ts b/common/src/envs/dev.ts new file mode 100644 index 0000000..5328a74 --- /dev/null +++ b/common/src/envs/dev.ts @@ -0,0 +1,25 @@ +import { EnvConfig, PROD_CONFIG } from './prod' + +export const DEV_CONFIG: EnvConfig = { + ...PROD_CONFIG, + domain: 'dev.manifold.love', + firebaseConfig: { + apiKey: "AIzaSyAxzhj6bZuZ1TCw9xzibGccRHXiRWq6iy0", + authDomain: "compass-130ba.firebaseapp.com", + projectId: "compass-130ba", + storageBucket: "compass-130ba.firebasestorage.app", + messagingSenderId: "253367029065", + appId: "1:253367029065:web:b338785af99d4145095e98", + measurementId: "G-2LSQYJQE6P", + region: 'us-west1', + privateBucket: 'polylove-private.firebasestorage.app', + }, + cloudRunId: 'w3txbmd3ba', + cloudRunRegion: 'uc', + supabaseInstanceId: 'ltzepxnhhnrnvovqblfr', + supabaseAnonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imx0emVweG5oaG5ybnZvdnFibGZyIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTU5NjczNjgsImV4cCI6MjA3MTU0MzM2OH0.pbazcrVOG7Kh_IgblRu2VAfoBe3-xheNfRzAto7xvzY', + apiEndpoint: 'api.dev.manifold.love', + adminIds: [ + '2cO953kN1sTBpfbhPVnTjRNqLJh2', // Sinclair + ], +} diff --git a/common/src/envs/is-prod.ts b/common/src/envs/is-prod.ts new file mode 100644 index 0000000..76d98ff --- /dev/null +++ b/common/src/envs/is-prod.ts @@ -0,0 +1,14 @@ +export const isProd = () => { + // For cloud run API service + if (process.env.ENVIRONMENT) { + return process.env.ENVIRONMENT == 'PROD' + // For local web dev and vercel + } else if (process.env.NEXT_PUBLIC_FIREBASE_ENV) { + return process.env.NEXT_PUBLIC_FIREBASE_ENV == 'PROD' + } else { + // For local scripts and cloud functions + // eslint-disable-next-line @typescript-eslint/no-var-requires + const admin = require('firebase-admin') + return admin.app().options.projectId === 'polylove' + } +} diff --git a/common/src/envs/prod.ts b/common/src/envs/prod.ts new file mode 100644 index 0000000..ab8b084 --- /dev/null +++ b/common/src/envs/prod.ts @@ -0,0 +1,57 @@ +export type EnvConfig = { + domain: string + firebaseConfig: FirebaseConfig + supabaseInstanceId: string + supabaseAnonKey: string + posthogKey: string + apiEndpoint: string + + // IDs for v2 cloud functions -- find these by deploying a cloud function and + // examining the URL, https://[name]-[cloudRunId]-[cloudRunRegion].a.run.app + cloudRunId: string + cloudRunRegion: string + + // Access controls + adminIds: string[] + + faviconPath: string // Should be a file in /public +} + +type FirebaseConfig = { + apiKey: string + authDomain: string + projectId: string + region?: string + storageBucket: string + privateBucket: string + messagingSenderId: string + appId: string + measurementId?: string +} + +export const PROD_CONFIG: EnvConfig = { + domain: 'manifold.love', + posthogKey: 'phc_7g8JXcONJQtsVEqOcSw4h2RzEEz5W40rD2WIjHC129h', + supabaseInstanceId: 'lltoaluoavlzrgjplire', + supabaseAnonKey: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImxsdG9hbHVvYXZsenJnanBsaXJlIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NDA0NTE4MDksImV4cCI6MjA1NjAyNzgwOX0.du6UI3YkcwUxTrYqYficcsT9zT5PTLsUYDIk_IkzVus', + firebaseConfig: { + apiKey: 'AIzaSyB_62i1KZ1_gk5vIlefi96G6lJ5dB0tXOo', + authDomain: 'polylove-57eab.firebaseapp.com', + projectId: 'polylove', + region: 'us-west1', + storageBucket: 'polylove.firebasestorage.app', + privateBucket: 'polylove-private.firebasestorage.app', + messagingSenderId: '226356461961', + appId: '1:226356461961:web:ff99f7f74861454e146158', + }, + apiEndpoint: 'api.poly.love', + cloudRunId: 'nggbo3neva', + cloudRunRegion: 'uc', + + adminIds: [ + '0k1suGSJKVUnHbCPEhHNpgZPkUP2', // Sinclair + ], + + faviconPath: '/favicon.ico', +} diff --git a/common/src/gender.ts b/common/src/gender.ts new file mode 100644 index 0000000..0e8e0ba --- /dev/null +++ b/common/src/gender.ts @@ -0,0 +1,40 @@ +export type Gender = + | 'male' + | 'female' + | 'non-binary' + | 'trans-male' + | 'trans-female' + +export function convertGender(gender: Gender) { + if (gender == 'male') { + return 'man' + } + if (gender == 'female') { + return 'woman' + } + if (gender == 'trans-female') { + return 'trans woman' + } + if (gender == 'trans-male') { + return 'trans man' + } + return gender +} + +export function convertGenderPlural(gender: Gender) { + if (gender == 'male') { + return 'men' + } + if (gender == 'female') { + return 'women' + } + if (gender == 'trans-female') { + return 'trans women' + } + if (gender == 'trans-male') { + return 'trans men' + } + return gender +} + +// TODO: support more gender expressions and politically inclusive copy diff --git a/common/src/love/compatibility-score.ts b/common/src/love/compatibility-score.ts new file mode 100644 index 0000000..8b330b0 --- /dev/null +++ b/common/src/love/compatibility-score.ts @@ -0,0 +1,145 @@ +import { keyBy, sumBy } from 'lodash' +import { LoverRow } from 'common/love/lover' +import { Row as rowFor } from 'common/supabase/utils' +import { + areAgeCompatible, + areLocationCompatible, + areRelationshipStyleCompatible, + areWantKidsCompatible, +} from './compatibility-util' + +const importanceToScore = { + 0: 0, + 1: 1, + 2: 5, + 3: 25, +} as { [importance: string]: number } + +export type CompatibilityScore = { + score: number + confidence: 'low' | 'medium' | 'high' +} + +export const getCompatibilityScore = ( + answers1: rowFor<'love_compatibility_answers'>[], + answers2: rowFor<'love_compatibility_answers'>[] +): CompatibilityScore => { + const { + score: score1, + maxScore: maxScore1, + answerCount, + } = getAnswersCompatibility(answers1, answers2) + const { score: score2, maxScore: maxScore2 } = getAnswersCompatibility( + answers2, + answers1 + ) + + // >=100 answers in common leads to no weight toward 50%. + // Use sqrt for diminishing returns to answering more questions. + const weightTowardFiftyPercent = Math.max( + 25 - 2.5 * Math.sqrt(answerCount), + 0 + ) + const upWeight = weightTowardFiftyPercent / 2 + const downWeight = weightTowardFiftyPercent + const compat1 = (score1 + upWeight) / (maxScore1 + downWeight) + const compat2 = (score2 + upWeight) / (maxScore2 + downWeight) + const geometricMean = Math.sqrt(compat1 * compat2) + + const confidence = + answerCount < 10 ? 'low' : answerCount < 100 ? 'medium' : 'high' + + return { score: geometricMean, confidence } +} + +const getAnswersCompatibility = ( + answers1: rowFor<'love_compatibility_answers'>[], + answers2: rowFor<'love_compatibility_answers'>[] +) => { + const answers2ByQuestionId = keyBy(answers2, 'question_id') + let maxScore = 0 + let answerCount = 0 + + const score = sumBy(answers1, (a) => { + if (a.importance === -1) return 0 + + const answer2 = answers2ByQuestionId[a.question_id] + // Not answered or skipped. + if (!answer2 || answer2.importance === -1) return 0 + + answerCount++ + const importanceScore = importanceToScore[a.importance] ?? 0 + maxScore += importanceScore + return getAnswerCompatibilityImportanceScore(a, answer2) + }) + + return { score, maxScore, answerCount } +} + +export function getAnswerCompatibilityImportanceScore( + answer1: rowFor<'love_compatibility_answers'>, + answer2: rowFor<'love_compatibility_answers'> +) { + const importanceScore = importanceToScore[answer1.importance] ?? 0 + return answer1.pref_choices.includes(answer2.multiple_choice) + ? importanceScore + : 0 +} + +export function getAnswerCompatibility( + answer1: rowFor<'love_compatibility_answers'>, + answer2: rowFor<'love_compatibility_answers'> +) { + if (answer1.importance < 0 || answer2.importance < 0) { + return false + } + + const compatibility1to2 = answer1.pref_choices.includes( + answer2.multiple_choice + ) + const compatibility2to1 = answer2.pref_choices.includes( + answer1.multiple_choice + ) + + return compatibility1to2 && compatibility2to1 +} + +export function getScoredAnswerCompatibility( + answer1: rowFor<'love_compatibility_answers'>, + answer2: rowFor<'love_compatibility_answers'> +) { + if (answer1.importance < 0 || answer2.importance < 0) { + return 0 + } + + const compatibility1to2 = +answer1.pref_choices.includes( + answer2.multiple_choice + ) + const compatibility2to1 = +answer2.pref_choices.includes( + answer1.multiple_choice + ) + const importanceCompatibility = + 1 - Math.abs(answer1.importance - answer2.importance) / 4 + + // Adjust these weights to change the impact of each component + const compatibilityWeight = 0.7 + const importanceWeight = 0.3 + + return ( + ((compatibility1to2 + compatibility2to1) * compatibilityWeight + + importanceCompatibility * importanceWeight) / + 2 + ) +} + +export const getLoversCompatibilityFactor = ( + lover1: LoverRow, + lover2: LoverRow +) => { + let multiplier = 1 + multiplier *= areAgeCompatible(lover1, lover2) ? 1 : 0.5 + multiplier *= areRelationshipStyleCompatible(lover1, lover2) ? 1 : 0.5 + multiplier *= areWantKidsCompatible(lover1, lover2) ? 1 : 0.5 + multiplier *= areLocationCompatible(lover1, lover2) ? 1 : 0.1 + return multiplier +} diff --git a/common/src/love/compatibility-util.ts b/common/src/love/compatibility-util.ts new file mode 100644 index 0000000..31c8ad7 --- /dev/null +++ b/common/src/love/compatibility-util.ts @@ -0,0 +1,70 @@ +import { LoverRow } from 'common/love/lover' + +const isPreferredGender = ( + preferredGenders: string[] | undefined, + gender: string | undefined +) => { + if (preferredGenders === undefined || gender === undefined) return true + + // If simple gender preference, don't include non-binary. + if ( + preferredGenders.length === 1 && + (preferredGenders[0] === 'male' || preferredGenders[0] === 'female') + ) { + return preferredGenders.includes(gender) + } + return preferredGenders.includes(gender) || gender === 'non-binary' +} + +export const areGenderCompatible = (lover1: LoverRow, lover2: LoverRow) => { + return ( + isPreferredGender(lover1.pref_gender, lover2.gender) && + isPreferredGender(lover2.pref_gender, lover1.gender) + ) +} + +const satisfiesAgeRange = (lover: LoverRow, age: number) => { + return age >= lover.pref_age_min && age <= lover.pref_age_max +} + +export const areAgeCompatible = (lover1: LoverRow, lover2: LoverRow) => { + return ( + satisfiesAgeRange(lover1, lover2.age) && + satisfiesAgeRange(lover2, lover1.age) + ) +} + +export const areLocationCompatible = (lover1: LoverRow, lover2: LoverRow) => { + if ( + !lover1.city_latitude || + !lover2.city_latitude || + !lover1.city_longitude || + !lover2.city_longitude + ) + return lover1.city.trim().toLowerCase() === lover2.city.trim().toLowerCase() + + const latitudeDiff = Math.abs(lover1.city_latitude - lover2.city_latitude) + const longigudeDiff = Math.abs(lover1.city_longitude - lover2.city_longitude) + + const root = (latitudeDiff ** 2 + longigudeDiff ** 2) ** 0.5 + return root < 2.5 +} + +export const areRelationshipStyleCompatible = ( + lover1: LoverRow, + lover2: LoverRow +) => { + return lover1.pref_relation_styles.some((style) => + lover2.pref_relation_styles.includes(style) + ) +} + +export const areWantKidsCompatible = (lover1: LoverRow, lover2: LoverRow) => { + const { wants_kids_strength: kids1 } = lover1 + const { wants_kids_strength: kids2 } = lover2 + + if (kids1 === undefined || kids2 === undefined) return true + + const diff = Math.abs(kids1 - kids2) + return diff <= 2 +} diff --git a/common/src/love/constants.ts b/common/src/love/constants.ts new file mode 100644 index 0000000..109e287 --- /dev/null +++ b/common/src/love/constants.ts @@ -0,0 +1,7 @@ +import { isProd } from 'common/envs/is-prod' + +export const manifoldLoveUserId = isProd() + ? 'tRZZ6ihugZQLXPf6aPRneGpWLmz1' + : 'RlXR2xa4EFfAzdCbSe45wkcdarh1' + +export const MAX_COMPATIBILITY_QUESTION_LENGTH = 240 diff --git a/common/src/love/lover.ts b/common/src/love/lover.ts new file mode 100644 index 0000000..e25ce51 --- /dev/null +++ b/common/src/love/lover.ts @@ -0,0 +1,10 @@ +import { Row, run, SupabaseClient } from 'common/supabase/utils' +import { User } from 'common/user' + +export type LoverRow = Row<'lovers'> +export type Lover = LoverRow & { user: User } +export const getLoverRow = async (userId: string, db: SupabaseClient) => { + console.log('getLoverRow', userId) + const res = await run(db.from('lovers').select('*').eq('user_id', userId)) + return res.data[0] +} diff --git a/common/src/love/multiple-choice.ts b/common/src/love/multiple-choice.ts new file mode 100644 index 0000000..0d3d193 --- /dev/null +++ b/common/src/love/multiple-choice.ts @@ -0,0 +1,15 @@ +export const MultipleChoiceOptions = { + 'Strongly disagree': 0, + Disagree: 1, + Neutral: 2, + Agree: 3, + 'Strongly agree': 4, +} + +export const MultipleChoiceColors = [ + 'bg-rose-600', + 'bg-rose-400', + 'bg-stone-400', + 'dark:bg-teal-200 bg-teal-300', + 'bg-teal-400', +] diff --git a/common/src/love/og-image.ts b/common/src/love/og-image.ts new file mode 100644 index 0000000..050f6f2 --- /dev/null +++ b/common/src/love/og-image.ts @@ -0,0 +1,28 @@ +import { User } from 'common/user' +import { LoverRow } from 'common/love/lover' +import { buildOgUrl } from 'common/util/og' + +// TODO: handle age, gender undefined better +export type LoveOgProps = { + // user props + avatarUrl: string + username: string + name: string + // lover props + age: string + city: string + gender: string +} + +export function getLoveOgImageUrl(user: User, lover?: LoverRow | null) { + const loveProps = { + avatarUrl: lover?.pinned_url, + username: user.username, + name: user.name, + age: lover?.age.toString() ?? '25', + city: lover?.city ?? 'Internet', + gender: lover?.gender ?? '???', + } as LoveOgProps + + return buildOgUrl(loveProps, 'lover', 'manifold.love') +} diff --git a/common/src/notifications.ts b/common/src/notifications.ts new file mode 100644 index 0000000..b616f83 --- /dev/null +++ b/common/src/notifications.ts @@ -0,0 +1,71 @@ +import { Row, SupabaseClient } from 'common/supabase/utils' + +export type Notification = { + id: string + userId: string + reasonText?: string + reason: string + createdTime: number + viewTime?: number + isSeen: boolean + + sourceId: string + sourceType: string + sourceUpdateType?: 'created' | 'updated' | 'deleted' + + sourceUserName: string + sourceUserUsername: string + sourceUserAvatarUrl: string + sourceText: string + data?: { [key: string]: any } + + sourceContractTitle?: string + sourceContractCreatorUsername?: string + sourceContractSlug?: string + + sourceSlug?: string + sourceTitle?: string + + isSeenOnHref?: string +} + +export const NOTIFICATION_TYPES_TO_SELECT = [ + 'new_match', // new match markets + 'comment_on_lover', // endorsements + 'love_like', + 'love_ship', +] + +export const NOTIFICATIONS_PER_PAGE = 30 + +export async function getNotifications( + db: SupabaseClient, + userId: string, + limit: number +) { + const { data } = await db + .from('user_notifications') + .select('*') + .eq('user_id', userId) + .order('data->createdTime', { ascending: false } as any) + .limit(limit) + return data?.map((d: Row<'user_notifications'>) => d) +} + +export async function getUnseenNotifications( + db: SupabaseClient, + userId: string, + limit: number +) { + const { data } = await db + .from('user_notifications') + .select('*') + .eq('user_id', userId) + .eq('data->>isSeen', 'false') + .order('data->createdTime', { ascending: false } as any) + .limit(limit) + + return data?.map((d: Row<'user_notifications'>) => d) ?? [] +} + +export type NotificationReason = any // TODO diff --git a/common/src/report.ts b/common/src/report.ts new file mode 100644 index 0000000..55b6ec3 --- /dev/null +++ b/common/src/report.ts @@ -0,0 +1,20 @@ +type Report = { + id: string + // Reporter user ID + userId: string + createdTime: number + contentOwnerId: string + contentType: ReportContentTypes + contentId: string + + // in case the user would like to say why they reported the content + description?: string + + // in the case of a comment, the comment's contract id + parentId?: string + parentType?: 'contract' | 'post' | 'user' +} + +export type ReportContentTypes = 'user' | 'comment' | 'contract' + +export type ReportProps = Omit diff --git a/common/src/secrets.ts b/common/src/secrets.ts new file mode 100644 index 0000000..fe85a2d --- /dev/null +++ b/common/src/secrets.ts @@ -0,0 +1,97 @@ +import { readFileSync } from 'fs' +import { SecretManagerServiceClient } from '@google-cloud/secret-manager' +import { zip } from 'lodash' + +// List of secrets that are available to backend (api, functions, scripts, etc.) +// Edit them at: +// https://console.cloud.google.com/security/secret-manager?project=polylove +export const secrets = ( + [ + // 'STRIPE_APIKEY', + // 'STRIPE_WEBHOOKSECRET', + 'SUPABASE_KEY', + 'SUPABASE_JWT_SECRET', + 'SUPABASE_PASSWORD', + 'TEST_CREATE_USER_KEY', + 'GEODB_API_KEY', + 'RESEND_KEY', + // Some typescript voodoo to keep the string literal types while being not readonly. + ] as const +).concat() + +type SecretId = (typeof secrets)[number] + +// Fetches all secrets from google cloud. +// For deployed google cloud service, no credential is needed. +// For local and Vercel deployments: requires credentials json object. +export const getSecrets = async (credentials?: any, ...ids: SecretId[]) => { + let client: SecretManagerServiceClient + if (credentials) { + const projectId = credentials['project_id'] + client = new SecretManagerServiceClient({ + credentials, + projectId, + }) + } else { + client = new SecretManagerServiceClient() + } + const projectId = await client.getProjectId() + + const secretIds = ids.length > 0 ? ids : secrets + + const fullSecretNames = secretIds.map( + (secret: string) => + `${client.projectPath(projectId)}/secrets/${secret}/versions/latest` + ) + + const secretResponses = await Promise.all( + fullSecretNames.map((name) => + client.accessSecretVersion({ + name, + }) + ) + ) + const secretValues = secretResponses.map(([response]) => + response.payload!.data!.toString() + ) + const pairs = zip(secretIds, secretValues) as [string, string][] + return Object.fromEntries(pairs) +} + +// Fetches all secrets and loads them into process.env. +// Useful for running random backend code. +export const loadSecretsToEnv = async (credentials?: any) => { + const allSecrets = await getSecrets(credentials) + for (const [key, value] of Object.entries(allSecrets)) { + if (key && value) { + process.env[key] = value + } + } +} + +// Get service account credentials from Vercel environment variable or local file. +export const getServiceAccountCredentials = (env: 'PROD' | 'DEV') => { + // Vercel environment variable for service credential. + const value = + env === 'PROD' + ? process.env.PROD_FIREBASE_SERVICE_ACCOUNT_KEY + : process.env.DEV_FIREBASE_SERVICE_ACCOUNT_KEY + if (value) { + return JSON.parse(value) + } + + // Local environment variable for service credential. + const envVar = `GOOGLE_APPLICATION_CREDENTIALS_${env}` + const keyPath = process.env[envVar] + if (keyPath == null) { + throw new Error( + `Please set the ${envVar} environment variable to contain the path to your ${env} environment key file.` + ) + } + + try { + return JSON.parse(readFileSync(keyPath, { encoding: 'utf8' })) + } catch (e) { + throw new Error(`Failed to load service account key from ${keyPath}.`) + } +} diff --git a/common/src/socials.test.ts b/common/src/socials.test.ts new file mode 100644 index 0000000..e1bfbb2 --- /dev/null +++ b/common/src/socials.test.ts @@ -0,0 +1,80 @@ +import { strip, getSocialUrl } from './socials' + +describe('strip', () => { + describe('x/twitter', () => { + it('should strip twitter.com URLs', () => { + expect(strip('x', 'https://twitter.com/username')).toBe('username') + expect(strip('x', 'https://x.com/username')).toBe('username') + expect(strip('x', 'twitter.com/username')).toBe('username') + expect(strip('x', 'x.com/username')).toBe('username') + expect(strip('x', '@username')).toBe('username') + expect(strip('x', 'username')).toBe('username') + }) + }) + + describe('github', () => { + it('should strip github URLs', () => { + expect(strip('github', 'https://github.com/username')).toBe('username') + expect(strip('github', 'github.com/username')).toBe('username') + expect(strip('github', '@username')).toBe('username') + expect(strip('github', 'username')).toBe('username') + }) + }) + + describe('instagram', () => { + it('should strip instagram URLs', () => { + expect(strip('instagram', 'https://instagram.com/username')).toBe('username') + expect(strip('instagram', 'instagram.com/username')).toBe('username') + expect(strip('instagram', '@username')).toBe('username') + expect(strip('instagram', 'username')).toBe('username') + }) + }) + + describe('bluesky', () => { + it('should strip bluesky URLs', () => { + expect(strip('bluesky', 'https://bsky.app/profile/username')).toBe('username') + expect(strip('bluesky', 'bsky.app/profile/username')).toBe('username') + expect(strip('bluesky', '@username')).toBe('username') + expect(strip('bluesky', 'username')).toBe('username') + }) + }) + + describe('mastodon', () => { + it('should handle mastodon handles', () => { + expect(strip('mastodon', '@user@instance.social')).toBe('user@instance.social') + expect(strip('mastodon', 'user@instance.social')).toBe('user@instance.social') + }) + }) + + describe('linkedin', () => { + it('should strip linkedin URLs', () => { + expect(strip('linkedin', 'https://linkedin.com/in/username')).toBe('username') + expect(strip('linkedin', 'linkedin.com/in/username')).toBe('username') + expect(strip('linkedin', 'https://linkedin.com/company/companyname')).toBe('companyname') + expect(strip('linkedin', 'username')).toBe('username') + }) + }) +}) + +describe('getSocialUrl', () => { + it('should generate correct URLs for each platform', () => { + expect(getSocialUrl('x', 'username')).toBe('https://x.com/username') + expect(getSocialUrl('github', 'username')).toBe('https://github.com/username') + expect(getSocialUrl('instagram', 'username')).toBe('https://instagram.com/username') + expect(getSocialUrl('bluesky', 'username')).toBe('https://bsky.app/profile/username') + expect(getSocialUrl('mastodon', 'user@instance.social')).toBe('https://instance.social/@user') + expect(getSocialUrl('linkedin', 'username')).toBe('https://linkedin.com/in/username') + expect(getSocialUrl('facebook', 'username')).toBe('https://facebook.com/username') + expect(getSocialUrl('spotify', 'username')).toBe('https://open.spotify.com/user/username') + }) + + it('should handle custom website URLs', () => { + expect(getSocialUrl('site', 'example.com')).toBe('https://example.com') + expect(getSocialUrl('site', 'https://example.com')).toBe('https://example.com') + }) + + it('should handle discord user IDs and default invite', () => { + expect(getSocialUrl('discord', '123456789012345678')).toBe('https://discord.com/users/123456789012345678') + expect(getSocialUrl('discord', 'not-an-id')).toBe('https://discord.com/invite/AYDw8dbrGS') + }) +}) \ No newline at end of file diff --git a/common/src/socials.ts b/common/src/socials.ts new file mode 100644 index 0000000..65144ee --- /dev/null +++ b/common/src/socials.ts @@ -0,0 +1,109 @@ +export const SITE_ORDER = [ + 'site', // personal site + 'x', // twitter + 'discord', + 'manifold', + 'bluesky', + 'mastodon', + 'substack', + 'onlyfans', + 'instagram', + 'github', + 'linkedin', + 'facebook', + 'spotify', +] as const + +export type Site = (typeof SITE_ORDER)[number] + +// this is a lie, actually people can have anything in their links +export type Socials = { [key in Site]?: string } + +export const strip = (site: Site, input: string) => + stripper[site]?.(input) ?? input + +const stripper: { [key in Site]: (input: string) => string } = { + site: (s) => s.replace(/^(https?:\/\/)/, ''), + x: (s) => + s + .replace(/^(https?:\/\/)?(www\.)?(twitter|x)(\.com\/)/, '') + .replace(/^@/, '') + .replace(/\/$/, ''), + discord: (s) => s, + manifold: (s) => + s + .replace(/^(https?:\/\/)?(manifold\.markets\/)/, '') + .replace(/^@/, '') + .replace(/\/$/, ''), + bluesky: (s) => + s + .replace(/^(https?:\/\/)?(www\.)?bsky\.app\/profile\//, '') + .replace(/^@/, '') + .replace(/\/$/, ''), + mastodon: (s) => s.replace(/^@/, ''), + substack: (s) => + s + .replace(/^(https?:\/\/)?(www\.)?(\w+\.)?substack\.com\//, '') + .replace(/\/$/, ''), + onlyfans: (s) => + s.replace(/^(https?:\/\/)?(www\.)?onlyfans\.com\//, '').replace(/\/$/, ''), + instagram: (s) => + s + .replace(/^(https?:\/\/)?(www\.)?instagram\.com\//, '') + .replace(/^@/, '') + .replace(/\/$/, ''), + github: (s) => + s + .replace(/^(https?:\/\/)?(www\.)?github\.com\//, '') + .replace(/^@/, '') + .replace(/\/$/, ''), + linkedin: (s) => + s + .replace(/^(https?:\/\/)?(www\.)?linkedin\.com\/(in|company)\//, '') + .replace(/\/$/, ''), + facebook: (s) => + s.replace(/^(https?:\/\/)?(www\.)?facebook\.com\//, '').replace(/\/$/, ''), + spotify: (s) => + s + .replace(/^(https?:\/\/)?(open\.)?spotify\.com\/(artist|user)\//, '') + .replace(/\/$/, ''), +} + +export const getSocialUrl = (site: Site, handle: string) => + urler[site]?.(handle) ?? urler.site(handle) + +const urler: { [key in Site]: (handle: string) => string } = { + site: (s) => (s.startsWith('http') ? s : `https://${s}`), + x: (s) => `https://x.com/${s}`, + discord: (s) => + (s.length === 17 || s.length === 18) && !isNaN(parseInt(s, 10)) + ? `https://discord.com/users/${s}` // discord user id + : 'https://discord.com/invite/AYDw8dbrGS', // our server + manifold: (s) => `https://manifold.markets/${s}`, + bluesky: (s) => `https://bsky.app/profile/${s}`, + mastodon: (s) => + s.includes('@') ? `https://${s.split('@')[1]}/@${s.split('@')[0]}` : s, + substack: (s) => `https://${s}.substack.com`, + onlyfans: (s) => `https://onlyfans.com/${s}`, + instagram: (s) => `https://instagram.com/${s}`, + github: (s) => `https://github.com/${s}`, + linkedin: (s) => `https://linkedin.com/in/${s}`, + facebook: (s) => `https://facebook.com/${s}`, + spotify: (s) => `https://open.spotify.com/user/${s}`, +} + +export const PLATFORM_LABELS: { [key in Site]: string } = { + site: 'Website', + x: 'Twitter/X', + discord: 'Discord', + manifold: 'Manifold', + bluesky: 'Bluesky', + mastodon: 'Mastodon', + substack: 'Substack', + onlyfans: 'OnlyFans', + instagram: 'Instagram', + github: 'GitHub', + linkedin: 'LinkedIn', + facebook: 'Facebook', + spotify: 'Spotify', +} diff --git a/common/src/supabase/comment.ts b/common/src/supabase/comment.ts new file mode 100644 index 0000000..becbb67 --- /dev/null +++ b/common/src/supabase/comment.ts @@ -0,0 +1,17 @@ +import { type JSONContent } from '@tiptap/core' +import { type Row, tsToMillis } from './utils' +import { type Comment } from 'common/comment' + +export const convertComment = (row: Row<'lover_comments'>): Comment => ({ + id: row.id + '', + userId: row.user_id, + commentType: 'lover', + onUserId: row.on_user_id, + createdTime: tsToMillis(row.created_time), + userName: row.user_name, + userUsername: row.user_username, + userAvatarUrl: row.user_avatar_url, + hidden: row.hidden, + visibility: 'public', + content: row.content as JSONContent, +}) diff --git a/common/src/supabase/private-messages.ts b/common/src/supabase/private-messages.ts new file mode 100644 index 0000000..8832d47 --- /dev/null +++ b/common/src/supabase/private-messages.ts @@ -0,0 +1,19 @@ +import { convertSQLtoTS, Row, tsToMillis } from 'common/supabase/utils' +import { ChatMessage, PrivateChatMessage } from 'common/chat-message' + +export type PrivateMessageChannel = { + channel_id: number + notify_after_time: string + created_time: string + last_updated_time: string +} + +export const convertChatMessage = (row: Row<'private_user_messages'>) => + convertSQLtoTS<'private_user_messages', ChatMessage>(row, { + created_time: tsToMillis as any, + }) + +export const convertPrivateChatMessage = (row: Row<'private_user_messages'>) => + convertSQLtoTS<'private_user_messages', PrivateChatMessage>(row, { + created_time: tsToMillis as any, + }) diff --git a/common/src/supabase/schema.ts b/common/src/supabase/schema.ts new file mode 100644 index 0000000..d65df13 --- /dev/null +++ b/common/src/supabase/schema.ts @@ -0,0 +1,834 @@ +export type Json = + | string + | number + | boolean + | null + | { [key: string]: Json | undefined } + | Json[] + +export type Database = { + public: { + Tables: { + love_answers: { + Row: { + created_time: string + creator_id: string + free_response: string | null + id: number + integer: number | null + multiple_choice: number | null + question_id: number + } + Insert: { + created_time?: string + creator_id: string + free_response?: string | null + id?: never + integer?: number | null + multiple_choice?: number | null + question_id: number + } + Update: { + created_time?: string + creator_id?: string + free_response?: string | null + id?: never + integer?: number | null + multiple_choice?: number | null + question_id?: number + } + Relationships: [] + } + love_compatibility_answers: { + Row: { + created_time: string + creator_id: string + explanation: string | null + id: number + importance: number + multiple_choice: number + pref_choices: number[] + question_id: number + } + Insert: { + created_time?: string + creator_id: string + explanation?: string | null + id?: never + importance: number + multiple_choice: number + pref_choices: number[] + question_id: number + } + Update: { + created_time?: string + creator_id?: string + explanation?: string | null + id?: never + importance?: number + multiple_choice?: number + pref_choices?: number[] + question_id?: number + } + Relationships: [] + } + love_likes: { + Row: { + created_time: string + creator_id: string + like_id: string + target_id: string + } + Insert: { + created_time?: string + creator_id: string + like_id?: string + target_id: string + } + Update: { + created_time?: string + creator_id?: string + like_id?: string + target_id?: string + } + Relationships: [] + } + love_questions: { + Row: { + answer_type: string + created_time: string + creator_id: string + id: number + importance_score: number + multiple_choice_options: Json | null + question: string + } + Insert: { + answer_type?: string + created_time?: string + creator_id: string + id?: never + importance_score?: number + multiple_choice_options?: Json | null + question: string + } + Update: { + answer_type?: string + created_time?: string + creator_id?: string + id?: never + importance_score?: number + multiple_choice_options?: Json | null + question?: string + } + Relationships: [] + } + love_ships: { + Row: { + created_time: string + creator_id: string + ship_id: string + target1_id: string + target2_id: string + } + Insert: { + created_time?: string + creator_id: string + ship_id?: string + target1_id: string + target2_id: string + } + Update: { + created_time?: string + creator_id?: string + ship_id?: string + target1_id?: string + target2_id?: string + } + Relationships: [] + } + love_stars: { + Row: { + created_time: string + creator_id: string + star_id: string + target_id: string + } + Insert: { + created_time?: string + creator_id: string + star_id?: string + target_id: string + } + Update: { + created_time?: string + creator_id?: string + star_id?: string + target_id?: string + } + Relationships: [] + } + love_waitlist: { + Row: { + created_time: string + email: string + id: number + } + Insert: { + created_time?: string + email: string + id?: never + } + Update: { + created_time?: string + email?: string + id?: never + } + Relationships: [] + } + lover_comments: { + Row: { + content: Json + created_time: string + hidden: boolean + id: number + on_user_id: string + reply_to_comment_id: number | null + user_avatar_url: string + user_id: string + user_name: string + user_username: string + } + Insert: { + content: Json + created_time?: string + hidden?: boolean + id?: never + on_user_id: string + reply_to_comment_id?: number | null + user_avatar_url: string + user_id: string + user_name: string + user_username: string + } + Update: { + content?: Json + created_time?: string + hidden?: boolean + id?: never + on_user_id?: string + reply_to_comment_id?: number | null + user_avatar_url?: string + user_id?: string + user_name?: string + user_username?: string + } + Relationships: [] + } + lovers: { + Row: { + age: number + bio: Json | null + born_in_location: string | null + city: string + city_latitude: number | null + city_longitude: number | null + comments_enabled: boolean + company: string | null + country: string | null + created_time: string + drinks_per_month: number | null + education_level: string | null + ethnicity: string[] | null + gender: string + geodb_city_id: string | null + has_kids: number | null + height_in_inches: number | null + id: number + is_smoker: boolean | null + is_vegetarian_or_vegan: boolean | null + last_online_time: string + looking_for_matches: boolean + messaging_status: string + occupation: string | null + occupation_title: string | null + photo_urls: string[] | null + pinned_url: string | null + political_beliefs: string[] | null + pref_age_max: number + pref_age_min: number + pref_gender: string[] + pref_relation_styles: string[] + referred_by_username: string | null + region_code: string | null + religious_belief_strength: number | null + religious_beliefs: string | null + twitter: string | null + university: string | null + user_id: string + visibility: Database['public']['Enums']['lover_visibility'] + wants_kids_strength: number + website: string | null + } + Insert: { + age?: number + bio?: Json | null + born_in_location?: string | null + city: string + city_latitude?: number | null + city_longitude?: number | null + comments_enabled?: boolean + company?: string | null + country?: string | null + created_time?: string + drinks_per_month?: number | null + education_level?: string | null + ethnicity?: string[] | null + gender: string + geodb_city_id?: string | null + has_kids?: number | null + height_in_inches?: number | null + id?: never + is_smoker?: boolean | null + is_vegetarian_or_vegan?: boolean | null + last_online_time?: string + looking_for_matches?: boolean + messaging_status?: string + occupation?: string | null + occupation_title?: string | null + photo_urls?: string[] | null + pinned_url?: string | null + political_beliefs?: string[] | null + pref_age_max?: number + pref_age_min?: number + pref_gender: string[] + pref_relation_styles: string[] + referred_by_username?: string | null + region_code?: string | null + religious_belief_strength?: number | null + religious_beliefs?: string | null + twitter?: string | null + university?: string | null + user_id: string + visibility?: Database['public']['Enums']['lover_visibility'] + wants_kids_strength?: number + website?: string | null + } + Update: { + age?: number + bio?: Json | null + born_in_location?: string | null + city?: string + city_latitude?: number | null + city_longitude?: number | null + comments_enabled?: boolean + company?: string | null + country?: string | null + created_time?: string + drinks_per_month?: number | null + education_level?: string | null + ethnicity?: string[] | null + gender?: string + geodb_city_id?: string | null + has_kids?: number | null + height_in_inches?: number | null + id?: never + is_smoker?: boolean | null + is_vegetarian_or_vegan?: boolean | null + last_online_time?: string + looking_for_matches?: boolean + messaging_status?: string + occupation?: string | null + occupation_title?: string | null + photo_urls?: string[] | null + pinned_url?: string | null + political_beliefs?: string[] | null + pref_age_max?: number + pref_age_min?: number + pref_gender?: string[] + pref_relation_styles?: string[] + referred_by_username?: string | null + region_code?: string | null + religious_belief_strength?: number | null + religious_beliefs?: string | null + twitter?: string | null + university?: string | null + user_id?: string + visibility?: Database['public']['Enums']['lover_visibility'] + wants_kids_strength?: number + website?: string | null + } + Relationships: [] + } + private_user_message_channel_members: { + Row: { + channel_id: number + created_time: string + id: number + notify_after_time: string + role: string + status: string + user_id: string + } + Insert: { + channel_id: number + created_time?: string + id?: never + notify_after_time?: string + role?: string + status?: string + user_id: string + } + Update: { + channel_id?: number + created_time?: string + id?: never + notify_after_time?: string + role?: string + status?: string + user_id?: string + } + Relationships: [ + { + foreignKeyName: 'channel_id_fkey' + columns: ['channel_id'] + isOneToOne: false + referencedRelation: 'private_user_message_channels' + referencedColumns: ['id'] + } + ] + } + private_user_message_channels: { + Row: { + created_time: string + id: number + last_updated_time: string + title: string | null + } + Insert: { + created_time?: string + id?: never + last_updated_time?: string + title?: string | null + } + Update: { + created_time?: string + id?: never + last_updated_time?: string + title?: string | null + } + Relationships: [] + } + private_user_messages: { + Row: { + channel_id: number + content: Json + created_time: string + id: number + old_id: number | null + user_id: string + visibility: string + } + Insert: { + channel_id: number + content: Json + created_time?: string + id?: never + old_id?: number | null + user_id: string + visibility?: string + } + Update: { + channel_id?: number + content?: Json + created_time?: string + id?: never + old_id?: number | null + user_id?: string + visibility?: string + } + Relationships: [ + { + foreignKeyName: 'private_user_messages_channel_id_fkey' + columns: ['channel_id'] + isOneToOne: false + referencedRelation: 'private_user_message_channels' + referencedColumns: ['id'] + } + ] + } + private_user_seen_message_channels: { + Row: { + channel_id: number + created_time: string + id: number + user_id: string + } + Insert: { + channel_id: number + created_time?: string + id?: never + user_id: string + } + Update: { + channel_id?: number + created_time?: string + id?: never + user_id?: string + } + Relationships: [ + { + foreignKeyName: 'channel_id_fkey' + columns: ['channel_id'] + isOneToOne: false + referencedRelation: 'private_user_message_channels' + referencedColumns: ['id'] + } + ] + } + private_users: { + Row: { + data: Json + id: string + } + Insert: { + data: Json + id: string + } + Update: { + data?: Json + id?: string + } + Relationships: [] + } + reports: { + Row: { + content_id: string + content_owner_id: string + content_type: string + created_time: string | null + description: string | null + id: string + parent_id: string | null + parent_type: string | null + user_id: string + } + Insert: { + content_id: string + content_owner_id: string + content_type: string + created_time?: string | null + description?: string | null + id?: string + parent_id?: string | null + parent_type?: string | null + user_id: string + } + Update: { + content_id?: string + content_owner_id?: string + content_type?: string + created_time?: string | null + description?: string | null + id?: string + parent_id?: string | null + parent_type?: string | null + user_id?: string + } + Relationships: [ + { + foreignKeyName: 'reports_content_owner_id_fkey' + columns: ['content_owner_id'] + isOneToOne: false + referencedRelation: 'users' + referencedColumns: ['id'] + }, + { + foreignKeyName: 'reports_user_id_fkey' + columns: ['user_id'] + isOneToOne: false + referencedRelation: 'users' + referencedColumns: ['id'] + } + ] + } + temp_users: { + Row: { + created_time: string | null + id: string | null + name: string | null + private_user_data: Json | null + user_data: Json | null + username: string | null + } + Insert: { + created_time?: string | null + id?: string | null + name?: string | null + private_user_data?: Json | null + user_data?: Json | null + username?: string | null + } + Update: { + created_time?: string | null + id?: string | null + name?: string | null + private_user_data?: Json | null + user_data?: Json | null + username?: string | null + } + Relationships: [] + } + user_events: { + Row: { + ad_id: string | null + comment_id: string | null + contract_id: string | null + data: Json + id: number + name: string + ts: string + user_id: string | null + } + Insert: { + ad_id?: string | null + comment_id?: string | null + contract_id?: string | null + data: Json + id?: never + name: string + ts?: string + user_id?: string | null + } + Update: { + ad_id?: string | null + comment_id?: string | null + contract_id?: string | null + data?: Json + id?: never + name?: string + ts?: string + user_id?: string | null + } + Relationships: [] + } + user_notifications: { + Row: { + data: Json + notification_id: string + user_id: string + } + Insert: { + data: Json + notification_id: string + user_id: string + } + Update: { + data?: Json + notification_id?: string + user_id?: string + } + Relationships: [] + } + users: { + Row: { + created_time: string + data: Json + id: string + name: string + name_username_vector: unknown | null + username: string + } + Insert: { + created_time?: string + data: Json + id?: string + name: string + name_username_vector?: unknown | null + username: string + } + Update: { + created_time?: string + data?: Json + id?: string + name?: string + name_username_vector?: unknown | null + username?: string + } + Relationships: [] + } + } + Views: { + [_ in never]: never + } + Functions: { + calculate_earth_distance_km: { + Args: { lat1: number; lon1: number; lat2: number; lon2: number } + Returns: number + } + can_access_private_messages: { + Args: { channel_id: number; user_id: string } + Returns: boolean + } + firebase_uid: { + Args: Record + Returns: string + } + get_average_rating: { + Args: { user_id: string } + Returns: number + } + get_compatibility_questions_with_answer_count: { + Args: Record + Returns: Record[] + } + get_love_question_answers_and_lovers: { + Args: { p_question_id: number } + Returns: Record[] + } + is_admin: { + Args: Record | { user_id: string } + Returns: boolean + } + millis_interval: { + Args: { start_millis: number; end_millis: number } + Returns: unknown + } + millis_to_ts: { + Args: { millis: number } + Returns: string + } + random_alphanumeric: { + Args: { length: number } + Returns: string + } + to_jsonb: { + Args: { '': Json } + Returns: Json + } + ts_to_millis: { + Args: { ts: string } | { ts: string } + Returns: number + } + } + Enums: { + lover_visibility: 'public' | 'member' + } + CompositeTypes: { + [_ in never]: never + } + } +} + +type DefaultSchema = Database[Extract] + +export type Tables< + DefaultSchemaTableNameOrOptions extends + | keyof (DefaultSchema['Tables'] & DefaultSchema['Views']) + | { schema: keyof Database }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof Database + } + ? keyof (Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'] & + Database[DefaultSchemaTableNameOrOptions['schema']]['Views']) + : never = never +> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database } + ? (Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'] & + Database[DefaultSchemaTableNameOrOptions['schema']]['Views'])[TableName] extends { + Row: infer R + } + ? R + : never + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema['Tables'] & + DefaultSchema['Views']) + ? (DefaultSchema['Tables'] & + DefaultSchema['Views'])[DefaultSchemaTableNameOrOptions] extends { + Row: infer R + } + ? R + : never + : never + +export type TablesInsert< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema['Tables'] + | { schema: keyof Database }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof Database + } + ? keyof Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'] + : never = never +> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database } + ? Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends { + Insert: infer I + } + ? I + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables'] + ? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends { + Insert: infer I + } + ? I + : never + : never + +export type TablesUpdate< + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema['Tables'] + | { schema: keyof Database }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof Database + } + ? keyof Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'] + : never = never +> = DefaultSchemaTableNameOrOptions extends { schema: keyof Database } + ? Database[DefaultSchemaTableNameOrOptions['schema']]['Tables'][TableName] extends { + Update: infer U + } + ? U + : never + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema['Tables'] + ? DefaultSchema['Tables'][DefaultSchemaTableNameOrOptions] extends { + Update: infer U + } + ? U + : never + : never + +export type Enums< + DefaultSchemaEnumNameOrOptions extends + | keyof DefaultSchema['Enums'] + | { schema: keyof Database }, + EnumName extends DefaultSchemaEnumNameOrOptions extends { + schema: keyof Database + } + ? keyof Database[DefaultSchemaEnumNameOrOptions['schema']]['Enums'] + : never = never +> = DefaultSchemaEnumNameOrOptions extends { schema: keyof Database } + ? Database[DefaultSchemaEnumNameOrOptions['schema']]['Enums'][EnumName] + : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema['Enums'] + ? DefaultSchema['Enums'][DefaultSchemaEnumNameOrOptions] + : never + +export type CompositeTypes< + PublicCompositeTypeNameOrOptions extends + | keyof DefaultSchema['CompositeTypes'] + | { schema: keyof Database }, + CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { + schema: keyof Database + } + ? keyof Database[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes'] + : never = never +> = PublicCompositeTypeNameOrOptions extends { schema: keyof Database } + ? Database[PublicCompositeTypeNameOrOptions['schema']]['CompositeTypes'][CompositeTypeName] + : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema['CompositeTypes'] + ? DefaultSchema['CompositeTypes'][PublicCompositeTypeNameOrOptions] + : never + +export const Constants = { + public: { + Enums: { + lover_visibility: ['public', 'member'], + }, + }, +} as const diff --git a/common/src/supabase/users.ts b/common/src/supabase/users.ts new file mode 100644 index 0000000..d941038 --- /dev/null +++ b/common/src/supabase/users.ts @@ -0,0 +1,35 @@ +import { PrivateUser, User } from 'common/user' +import { Row, run, SupabaseClient, tsToMillis } from './utils' + +export async function getUserForStaticProps( + db: SupabaseClient, + username: string +) { + const { data } = await run( + db.from('users').select().ilike('username', username) + ) + return convertUser(data[0] ?? null) +} + +export function convertUser(row: Row<'users'>): User +export function convertUser(row: Row<'users'> | null): User | null { + if (!row) return null + + return { + ...(row.data as any), + id: row.id, + username: row.username, + name: row.name, + createdTime: tsToMillis(row.created_time), + } as User +} + +export function convertPrivateUser(row: Row<'private_users'>): PrivateUser +export function convertPrivateUser( + row: Row<'private_users'> | null +): PrivateUser | null { + if (!row) return null + return row.data as PrivateUser +} + +export const displayUserColumns = `id,name,username,data->>'avatarUrl' as "avatarUrl",data->'isBannedFromPosting' as "isBannedFromPosting"` diff --git a/common/src/supabase/utils.ts b/common/src/supabase/utils.ts new file mode 100644 index 0000000..6e84d81 --- /dev/null +++ b/common/src/supabase/utils.ts @@ -0,0 +1,160 @@ +import { + createClient as createClientGeneric, + PostgrestResponse, + PostgrestSingleResponse, + SupabaseClient as SupabaseClientGeneric, + SupabaseClientOptions as SupabaseClientOptionsGeneric, +} from '@supabase/supabase-js' + +import { Database } from './schema' +import { User } from '../user' + +export type Schema = Database['public'] +export type Tables = Schema['Tables'] +export type Views = Schema['Views'] +export type TableName = keyof Tables +export type ViewName = keyof Views +export type Selectable = TableName | ViewName +export type Row = T extends TableName + ? Tables[T]['Row'] + : T extends ViewName + ? Views[T]['Row'] + : never +export type Column = keyof Row & string + +export type SupabaseClient = SupabaseClientGeneric + +export function createClient( + instanceId: string, + key: string, + opts?: SupabaseClientOptionsGeneric<'public'> +) { + const url = `https://${instanceId}.supabase.co` + return createClientGeneric(url, key, opts) as SupabaseClient +} + +export type QueryResponse = PostgrestResponse | PostgrestSingleResponse +export type QueryMultiSuccessResponse = { data: T[]; count: number } +export type QuerySingleSuccessResponse = { data: T; count: number } + +export async function run( + q: PromiseLike> +): Promise> +export async function run( + q: PromiseLike> +): Promise> +export async function run( + q: PromiseLike | PostgrestResponse> +) { + const { data, count, error } = await q + if (error != null) { + throw error + } else { + return { data, count } + } +} + +type JsonTypes = { + users: User +} + +export type DataFor = T extends keyof JsonTypes + ? JsonTypes[T] + : any + +export function selectJson( + db: SupabaseClient, + table: T +) { + return db.from(table).select }>('data') +} + +export function selectFrom< + T extends TableName, + TData extends DataFor, + TFields extends (string & keyof TData)[], + TResult = Pick +>(db: SupabaseClient, table: T, ...fields: TFields) { + const query = fields.map((f) => `data->${f}`).join(', ') + return db.from(table).select(query) +} + +export function millisToTs(millis: number) { + return new Date(millis).toISOString() +} + +export function tsToMillis(ts: string) { + return Date.parse(ts) +} + +type SnakeToCamel = S extends `${infer T}_${infer U}` + ? `${Lowercase}${Capitalize>}` + : S + +const camelize = (s: S) => + s.replace(/(_\w)/g, (m) => m[1].toUpperCase()) as SnakeToCamel + +// sql column -> converter function or false +type TypeConverter> = { + [key in Column]?: SnakeToCamel extends keyof T + ? ((r: Row[key]) => T[SnakeToCamel]) | false + : false +} + +/** + * Convert a sql row to its frontend data type. + * Changes snake_case to camelCase. + * You can also specify conversion functions for each column, or set it to false to filter it. + */ +export const convertSQLtoTS = < + R extends Selectable, + T extends Record +>( + sqlData: Partial & { data: any }>, + converters: TypeConverter, + expandData = true, + shouldCamelize = true +) => { + const { data = {}, ...rows } = sqlData + + const entries = Object.entries(rows) + + const m = entries + .map((entry) => { + const [key, val] = entry as [Column, Row[Column]] + + const convert = converters[key] + if (convert === false) return null + const jsProp = shouldCamelize ? camelize(key) : key + const jsVal = convert != null ? convert(val) : val + return [jsProp, jsVal] + }) + .filter((x) => x != null) + + const newRows = Object.fromEntries(m as any) + if (expandData) return { ...data, ...newRows } as T + else return { ...newRows } as T +} + +export const convertObjectToSQLRow = < + T extends Record, + R extends Selectable +>( + objData: Partial +) => { + const entries = Object.entries(objData) + + const m = entries + .map((entry) => { + const [key, val] = entry as [string, T[keyof T]] + + const decamelizeKey = key.replace(/([A-Z])/g, '_$1').toLowerCase() + + return [decamelizeKey, val] + }) + .filter((x) => x != null) + + const newRows = Object.fromEntries(m as any) + + return newRows as Partial & { data: any }> +} diff --git a/common/src/user-notification-preferences.ts b/common/src/user-notification-preferences.ts new file mode 100644 index 0000000..3110eee --- /dev/null +++ b/common/src/user-notification-preferences.ts @@ -0,0 +1,76 @@ +import { PrivateUser } from './user' +import { filterDefined } from './util/array' + +export type notification_destination_types = 'email' | 'browser' | 'mobile' +export type notification_preference = keyof notification_preferences +export type notification_preferences = { + // Manifold.love + new_match: notification_destination_types[] + new_endorsement: notification_destination_types[] + new_love_like: notification_destination_types[] + new_love_ship: notification_destination_types[] + + // User-related + new_message: notification_destination_types[] + tagged_user: notification_destination_types[] + on_new_follow: notification_destination_types[] + + // General + onboarding_flow: notification_destination_types[] // unused + thank_you_for_purchases: notification_destination_types[] // unused + opt_out_all: notification_destination_types[] +} + +export const getDefaultNotificationPreferences = (isDev?: boolean) => { + const constructPref = ( + browserIf: boolean, + emailIf: boolean, + mobileIf: boolean + ) => { + const browser = browserIf ? 'browser' : undefined + const email = isDev ? undefined : emailIf ? 'email' : undefined + const mobile = mobileIf ? 'mobile' : undefined + return filterDefined([ + browser, + email, + mobile, + ]) as notification_destination_types[] + } + const defaults: notification_preferences = { + // Manifold.love + new_match: constructPref(true, true, true), + new_endorsement: constructPref(true, true, true), + new_love_like: constructPref(true, false, false), + new_love_ship: constructPref(true, false, false), + + // User-related + new_message: constructPref(true, true, true), + tagged_user: constructPref(true, true, true), + on_new_follow: constructPref(true, true, false), + + // General + thank_you_for_purchases: constructPref(false, false, false), + onboarding_flow: constructPref(true, true, false), + + opt_out_all: [], + } + return defaults +} + +export const getNotificationDestinationsForUser = ( + privateUser: PrivateUser, + type: notification_preference +) => { + const destinations = privateUser.notificationPreferences[type] + const opt_out = privateUser.notificationPreferences.opt_out_all + + return { + sendToEmail: destinations.includes('email') && !opt_out.includes('email'), + sendToBrowser: + destinations.includes('browser') && !opt_out.includes('browser'), + sendToMobile: + destinations.includes('mobile') && !opt_out.includes('mobile'), + unsubscribeUrl: 'TODO', + urlToManageThisNotification: '/notifications', + } +} diff --git a/common/src/user.ts b/common/src/user.ts new file mode 100644 index 0000000..1a65afb --- /dev/null +++ b/common/src/user.ts @@ -0,0 +1,58 @@ +import { Socials } from './socials' +import { notification_preferences } from './user-notification-preferences' + +export type User = { + id: string + createdTime: number + + name: string + username: string + avatarUrl: string + + // For their user page + bio?: string + + // Social links + link: Socials + + // Legacy fields (deprecated) + /** @deprecated Use link.site instead */ + website?: string + /** @deprecated Use link.x instead */ + twitterHandle?: string + /** @deprecated Use link.discord instead */ + discordHandle?: string + /** @deprecated Use link.manifold instead */ + manifoldHandle?: string + + isBannedFromPosting?: boolean + userDeleted?: boolean + + // fromLove?: boolean // whether originally from manifold.love back when it was synced to manifold + // fromManifold?: boolean // whether has a manifold.markets account + + sweestakesVerified?: boolean + verifiedPhone?: boolean + idVerified?: boolean +} + +export type PrivateUser = { + id: string // same as User.id + email?: string + initialDeviceToken?: string + initialIpAddress?: string + notificationPreferences: notification_preferences + blockedUserIds: string[] + blockedByUserIds: string[] +} + +export type UserAndPrivateUser = { user: User; privateUser: PrivateUser } + +export const MANIFOLD_LOVE_LOGO = + 'https://manifold.markets/manifold_love_logo.svg' + +export function getCurrentUtcTime(): Date { + const currentDate = new Date() + const utcDate = currentDate.toISOString() + return new Date(utcDate) +} diff --git a/common/src/util/algos.ts b/common/src/util/algos.ts new file mode 100644 index 0000000..7fe2118 --- /dev/null +++ b/common/src/util/algos.ts @@ -0,0 +1,31 @@ +export function binarySearch( + min: number, + max: number, + comparator: (x: number) => number +) { + let mid = 0 + let i = 0 + while (true) { + mid = min + (max - min) / 2 + + // Break once we've reached max precision. + if (mid === min || mid === max) break + + const comparison = comparator(mid) + if (comparison === 0) break + else if (comparison > 0) { + max = mid + } else { + min = mid + } + + i++ + if (i > 100000) { + throw new Error( + 'Binary search exceeded max iterations' + + JSON.stringify({ min, max, mid, i }, null, 2) + ) + } + } + return mid +} diff --git a/common/src/util/api.ts b/common/src/util/api.ts new file mode 100644 index 0000000..9005cd3 --- /dev/null +++ b/common/src/util/api.ts @@ -0,0 +1,77 @@ +import { API, APIParams, APIPath, APIResponse } from 'common/api/schema' +import { APIError, getApiUrl } from 'common/api/utils' +import { forEach } from 'lodash' +import { removeUndefinedProps } from 'common/util/object' +import { User } from 'firebase/auth' + +export function unauthedApi

(path: P, params: APIParams

) { + return typedAPICall(path, params, null) +} + +export const typedAPICall =

( + path: P, + params: APIParams

, + user: User | null +) => { + // parse any params that should part of the path (like market/:id) + const newParams: any = {} + let url = getApiUrl(path) + forEach(params, (v, k) => { + if (url.includes(`:${k}`)) { + url = url.replace(`:${k}`, v + '') + } else { + newParams[k] = v + } + }) + + return baseApiCall({ + url, + method: API[path].method, + params: newParams, + user, + }) as Promise> +} + +function appendQuery(url: string, props: Record) { + const [base, query] = url.split(/\?(.+)/) + const params = new URLSearchParams(query) + forEach(removeUndefinedProps(props ?? {}), (v, k) => { + if (Array.isArray(v)) { + v.forEach((item) => params.append(k, item)) + } else { + params.set(k, v) + } + }) + return `${base}?${params.toString()}` +} + +export async function baseApiCall(props: { + url: string + method: 'POST' | 'PUT' | 'GET' + params: any + user: User | null +}) { + const { url, method, params, user } = props + + const actualUrl = method === 'POST' ? url : appendQuery(url, params) + const headers: HeadersInit = { + 'Content-Type': 'application/json', + } + if (user) { + const token = await user.getIdToken() + headers.Authorization = `Bearer ${token}` + } + const req = new Request(actualUrl, { + headers, + method: method, + body: + params == null || method === 'GET' ? undefined : JSON.stringify(params), + }) + return fetch(req).then(async (resp) => { + const json = (await resp.json()) as { [k: string]: any } + if (!resp.ok) { + throw new APIError(resp.status as any, json?.message, json?.details) + } + return json + }) +} diff --git a/common/src/util/array.ts b/common/src/util/array.ts new file mode 100644 index 0000000..f523b20 --- /dev/null +++ b/common/src/util/array.ts @@ -0,0 +1,34 @@ +import { compact, flattenDeep, isEqual } from 'lodash' + +export const arrify = (maybeArr: T | T[]) => + Array.isArray(maybeArr) ? maybeArr : [maybeArr] + +export function filterDefined(array: (T | null | undefined)[]) { + return array.filter((item) => item !== null && item !== undefined) as T[] +} + +type Falsey = false | undefined | null | 0 | '' +type FalseyValueArray = T | Falsey | FalseyValueArray[] + +export function buildArray(...params: FalseyValueArray[]) { + return compact(flattenDeep(params)) as T[] +} + +export function groupConsecutive(xs: T[], key: (x: T) => U) { + if (!xs.length) { + return [] + } + const result = [] + let curr = { key: key(xs[0]), items: [xs[0]] } + for (const x of xs.slice(1)) { + const k = key(x) + if (!isEqual(k, curr.key)) { + result.push(curr) + curr = { key: k, items: [x] } + } else { + curr.items.push(x) + } + } + result.push(curr) + return result +} diff --git a/common/src/util/clean-username.ts b/common/src/util/clean-username.ts new file mode 100644 index 0000000..7e2080f --- /dev/null +++ b/common/src/util/clean-username.ts @@ -0,0 +1,12 @@ +export const cleanUsername = (name: string, maxLength = 25) => { + return name + .replace(/\s+/g, '') + .normalize('NFD') // split an accented letter in the base letter and the accent + .replace(/[\u0300-\u036f]/g, '') // remove all previously split accents + .replace(/[^A-Za-z0-9_]/g, '') // remove all chars not letters, numbers and underscores + .substring(0, maxLength) +} + +export const cleanDisplayName = (displayName: string, maxLength = 30) => { + return displayName.replace(/\s+/g, ' ').substring(0, maxLength).trim() +} diff --git a/common/src/util/color.ts b/common/src/util/color.ts new file mode 100644 index 0000000..fb15cc6 --- /dev/null +++ b/common/src/util/color.ts @@ -0,0 +1,24 @@ +export const interpolateColor = (color1: string, color2: string, p: number) => { + const rgb1 = parseInt(color1.replace('#', ''), 16) + const rgb2 = parseInt(color2.replace('#', ''), 16) + + const [r1, g1, b1] = toArray(rgb1) + const [r2, g2, b2] = toArray(rgb2) + + const q = 1 - p + const rr = Math.round(r1 * q + r2 * p) + const rg = Math.round(g1 * q + g2 * p) + const rb = Math.round(b1 * q + b2 * p) + + const hexString = Number((rr << 16) + (rg << 8) + rb).toString(16) + const hex = `#${'0'.repeat(6 - hexString.length)}${hexString}` + return hex +} + +function toArray(rgb: number) { + const r = rgb >> 16 + const g = (rgb >> 8) % 256 + const b = rgb % 256 + + return [r, g, b] +} diff --git a/common/src/util/format.ts b/common/src/util/format.ts new file mode 100644 index 0000000..0c98467 --- /dev/null +++ b/common/src/util/format.ts @@ -0,0 +1,36 @@ +function getPercentDecimalPlaces(zeroToOne: number) { + return zeroToOne < 0.02 || zeroToOne > 0.98 ? 1 : 0 +} + +export function formatPercent(zeroToOne: number) { + // Show 1 decimal place if <2% or >98%, giving more resolution on the tails + const decimalPlaces = getPercentDecimalPlaces(zeroToOne) + const percent = zeroToOne * 100 + return percent.toFixed(decimalPlaces) + '%' +} + +// returns a string no longer than 4 characters +export function shortenNumber(num: number): string { + if (num < 1e3) return Math.round(num).toString() // less than 1000 + if (num >= 1e3 && num < 1e6) { + const rounded = Math.round(num / 100) / 10 + return rounded.toFixed(rounded < 10 ? 1 : 0) + 'k' // Ensuring the total length is 4 or less + } + if (num >= 1e6 && num < 1e9) { + const rounded = Math.round(num / 1e5) / 10 + return rounded.toFixed(rounded < 10 ? 1 : 0) + 'M' + } + if (num >= 1e9 && num < 1e12) { + const rounded = Math.round(num / 1e8) / 10 + return rounded.toFixed(rounded < 10 ? 1 : 0) + 'B' + } + if (num >= 1e12) { + const rounded = Math.round(num / 1e11) / 10 + return rounded.toFixed(rounded < 10 ? 1 : 0) + 'T' + } + return num.toString() // Fallback, ideally never hit if all cases are covered +} + +export function numberWithCommas(x: number) { + return x.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') +} diff --git a/common/src/util/json.ts b/common/src/util/json.ts new file mode 100644 index 0000000..bbdf56e --- /dev/null +++ b/common/src/util/json.ts @@ -0,0 +1,7 @@ +export const safeJsonParse = (json: string | undefined | null) => { + try { + return JSON.parse(json ?? '') + } catch (e) { + return null + } +} diff --git a/common/src/util/math.ts b/common/src/util/math.ts new file mode 100644 index 0000000..1094233 --- /dev/null +++ b/common/src/util/math.ts @@ -0,0 +1,52 @@ +import { sortBy, sum } from 'lodash' + +export const logInterpolation = (min: number, max: number, value: number) => { + if (value <= min) return 0 + if (value >= max) return 1 + + return Math.log(value - min + 1) / Math.log(max - min + 1) +} + +export const logit = (x: number) => Math.log(x / (1 - x)) + +export function median(xs: number[]) { + if (xs.length === 0) return NaN + + const sorted = sortBy(xs, (x) => x) + const mid = Math.floor(sorted.length / 2) + if (sorted.length % 2 === 0) { + return (sorted[mid - 1] + sorted[mid]) / 2 + } + return sorted[mid] +} + +export function average(xs: number[]) { + return xs.length === 0 ? 0 : sum(xs) / xs.length +} + +export function sumOfSquaredError(xs: number[]) { + const mean = average(xs) + let total = 0 + for (const x of xs) { + const error = x - mean + total += error * error + } + return total +} + +export const EPSILON = 0.00000001 + +export function floatingEqual(a: number, b: number, epsilon = EPSILON) { + return Math.abs(a - b) < epsilon +} +export function floatingGreater(a: number, b: number, epsilon = EPSILON) { + return a - epsilon > b +} + +export function floatingGreaterEqual(a: number, b: number, epsilon = EPSILON) { + return a + epsilon >= b +} + +export function floatingLesserEqual(a: number, b: number, epsilon = EPSILON) { + return a - epsilon <= b +} diff --git a/common/src/util/matrix.ts b/common/src/util/matrix.ts new file mode 100644 index 0000000..98a19ad --- /dev/null +++ b/common/src/util/matrix.ts @@ -0,0 +1,99 @@ +import { max, sumBy } from 'lodash' + +// each row has [column, value] pairs +type SparseMatrix = [number, number][][] + +// Code originally from: https://github.com/johnpaulada/matrix-factorization-js/blob/master/src/matrix-factorization.js +// Used to implement recommendations through collaborative filtering: https://towardsdatascience.com/recommender-systems-matrix-factorization-using-pytorch-bd52f46aa199 +// See also: https://en.wikipedia.org/wiki/Matrix_factorization_(recommender_systems) + +/** + * Gets the factors of a sparse matrix + * + * @param TARGET_MATRIX target matrix, where each row specifies a subset of all columns. + * @param FEATURES Number of latent features + * @param ITERS Number of times to move towards the real factors + * @param LEARNING_RATE Learning rate + * @param REGULARIZATION_RATE Regularization amount, i.e. amount of bias reduction + * @returns An array containing the two factor matrices + */ +export function factorizeMatrix( + TARGET_MATRIX: SparseMatrix, + FEATURES = 5, + ITERS = 5000, + LEARNING_RATE = 0.0002, + REGULARIZATION_RATE = 0.02, + THRESHOLD = 0.001 +) { + const initCell = () => (2 * Math.random()) / FEATURES + const m = TARGET_MATRIX.length + const n = (max(TARGET_MATRIX.flatMap((r) => r.map(([j]) => j))) ?? -1) + 1 + const points = sumBy(TARGET_MATRIX, (r) => r.length) + const mFeatures = fillMatrix(m, FEATURES, initCell) + const nFeatures = fillMatrix(n, FEATURES, initCell) + + console.log('rows', m, 'columns', n, 'numPoints', points) + + const updateFeature = (a: number, b: number, error: number) => + a + LEARNING_RATE * (2 * error * b - REGULARIZATION_RATE * a) + + const dotProduct = (i: number, j: number) => { + let result = 0 + for (let k = 0; k < FEATURES; k++) { + result += mFeatures[i * FEATURES + k] * nFeatures[j * FEATURES + k] + } + return result + } + + // Iteratively figure out correct factors. + for (let iter = 0; iter < ITERS; iter++) { + for (let i = 0; i < m; i++) { + for (const [j, targetValue] of TARGET_MATRIX[i]) { + // to approximate the value for target_ij, we take the dot product of the features for m[i] and n[j] + const error = targetValue - dotProduct(i, j) + // update factor matrices + for (let k = 0; k < FEATURES; k++) { + const a = mFeatures[i * FEATURES + k] + const b = nFeatures[j * FEATURES + k] + mFeatures[i * FEATURES + k] = updateFeature(a, b, error) + nFeatures[j * FEATURES + k] = updateFeature(b, a, error) + } + } + } + + if (iter % 50 === 0 || iter === ITERS - 1) { + let totalError = 0 + for (let i = 0; i < m; i++) { + for (const [j, targetValue] of TARGET_MATRIX[i]) { + // add up squared error of current approximated value + totalError += (targetValue - dotProduct(i, j)) ** 2 + // mqp: idk what this part of the error means lol + for (let k = 0; k < FEATURES; k++) { + const a = mFeatures[i * FEATURES + k] + const b = nFeatures[j * FEATURES + k] + totalError += (REGULARIZATION_RATE / 2) * (a ** 2 + b ** 2) + } + } + } + console.log(iter, 'error', totalError / points) + + // Complete factorization process if total error falls below a certain threshold + if (totalError / points < THRESHOLD) break + } + } + + return [mFeatures, nFeatures, dotProduct] as const +} + +/** + * Creates an m x n matrix filled with the result of given fill function. + */ +function fillMatrix(m: number, n: number, fill: () => number) { + const matrix = new Float64Array(m * n) + for (let i = 0; i < m; i++) { + for (let j = 0; j < n; j++) { + matrix[i * n + j] = fill() + } + } + return matrix +} diff --git a/common/src/util/object.ts b/common/src/util/object.ts new file mode 100644 index 0000000..4021b2a --- /dev/null +++ b/common/src/util/object.ts @@ -0,0 +1,89 @@ +import { isEqual, mapValues, union } from 'lodash' + +export const removeUndefinedProps = (obj: T): T => { + const newObj: any = {} + + for (const key of Object.keys(obj)) { + if ((obj as any)[key] !== undefined) newObj[key] = (obj as any)[key] + } + + return newObj +} +export const removeNullOrUndefinedProps = ( + obj: T, + exceptions?: string[] +): T => { + const newObj: any = {} + + for (const key of Object.keys(obj)) { + if ( + ((obj as any)[key] !== undefined && (obj as any)[key] !== null) || + (exceptions ?? []).includes(key) + ) + newObj[key] = (obj as any)[key] + } + return newObj +} + +export const addObjects = ( + obj1: T, + obj2: T +) => { + const keys = union(Object.keys(obj1), Object.keys(obj2)) + const newObj = {} as any + + for (const key of keys) { + newObj[key] = (obj1[key] ?? 0) + (obj2[key] ?? 0) + } + + return newObj as T +} + +export const subtractObjects = ( + obj1: T, + obj2: T +) => { + const keys = union(Object.keys(obj1), Object.keys(obj2)) + const newObj = {} as any + + for (const key of keys) { + newObj[key] = (obj1[key] ?? 0) - (obj2[key] ?? 0) + } + + return newObj as T +} + +export const hasChanges = (obj: T, partial: Partial) => { + const currValues = mapValues(partial, (_, key: keyof T) => obj[key]) + return !isEqual(currValues, partial) +} + +export const hasSignificantDeepChanges = ( + obj: T, + partial: Partial, + epsilonForNumbers: number +): boolean => { + const compareValues = (currValue: any, partialValue: any): boolean => { + if (typeof currValue === 'number' && typeof partialValue === 'number') { + return Math.abs(currValue - partialValue) > epsilonForNumbers + } + if (typeof currValue === 'object' && typeof partialValue === 'object') { + return hasSignificantDeepChanges( + currValue, + partialValue, + epsilonForNumbers + ) + } + return !isEqual(currValue, partialValue) + } + + for (const key in partial) { + if (Object.prototype.hasOwnProperty.call(partial, key)) { + if (compareValues(obj[key], partial[key])) { + return true + } + } + } + + return false +} diff --git a/common/src/util/og.ts b/common/src/util/og.ts new file mode 100644 index 0000000..0de5967 --- /dev/null +++ b/common/src/util/og.ts @@ -0,0 +1,19 @@ +import { DOMAIN } from 'common/envs/constants' + +// opengraph functions that run in static props or client-side, but not in the edge (in image creation) + +export function buildOgUrl

>( + props: P, + endpoint: string, + domain?: string +) { + const generateUrlParams = (params: P) => + new URLSearchParams(params).toString() + + // Change to localhost:3000 for local testing + const url = + // `http://localhost:3000/api/og/${endpoint}?` + generateUrlParams(props) + `https://${domain ?? DOMAIN}/api/og/${endpoint}?` + generateUrlParams(props) + + return url +} diff --git a/common/src/util/parse.ts b/common/src/util/parse.ts new file mode 100644 index 0000000..74713b1 --- /dev/null +++ b/common/src/util/parse.ts @@ -0,0 +1,79 @@ +import { + getText, + getSchema, + getTextSerializersFromSchema, + Node, + JSONContent, +} from '@tiptap/core' +import { Node as ProseMirrorNode } from '@tiptap/pm/model' +import { StarterKit } from '@tiptap/starter-kit' +import { Image } from '@tiptap/extension-image' +import { Link } from '@tiptap/extension-link' +import { Mention } from '@tiptap/extension-mention' +import Iframe from './tiptap-iframe' +import { find } from 'linkifyjs' +import { uniq } from 'lodash' +import { compareTwoStrings } from 'string-similarity' + +/** get first url in text. like "notion.so " -> "http://notion.so"; "notion" -> null */ +export function getUrl(text: string) { + const results = find(text, 'url') + return results.length ? results[0].href : null +} + +export const beginsWith = (text: string, query: string) => + text.toLocaleLowerCase().startsWith(query.toLocaleLowerCase()) + +export const wordIn = (word: string, corpus: string) => { + word = word.toLocaleLowerCase() + corpus = corpus.toLocaleLowerCase() + + return corpus.includes(word) || compareTwoStrings(word, corpus) > 0.7 +} + +const checkAgainstQuery = (query: string, corpus: string) => + query.split(' ').every((word) => wordIn(word, corpus)) + +export const searchInAny = (query: string, ...fields: string[]) => + fields.some((field) => checkAgainstQuery(query, field)) + +/** @return user ids of all \@mentions */ +export function parseMentions(data: JSONContent): string[] { + const mentions = data.content?.flatMap(parseMentions) ?? [] //dfs + if (data.type === 'mention' && data.attrs) { + mentions.push(data.attrs.id as string) + } + return uniq(mentions) +} + +export const extensions = [ + StarterKit, + Link, + Image.extend({ renderText: () => '[image]' }), + Mention, // user @mention + Iframe.extend({ + renderText: ({ node }) => + '[embed]' + node.attrs.src ? `(${node.attrs.src})` : '', + }), +] + +const extensionSchema = getSchema(extensions) +const extensionSerializers = getTextSerializersFromSchema(extensionSchema) + +export function richTextToString(text?: JSONContent) { + if (!text) return '' + try { + const node = ProseMirrorNode.fromJSON(extensionSchema, text) + return getText(node, { + blockSeparator: '\n\n', + textSerializers: extensionSerializers, + }) + } catch (e) { + console.error('error parsing rich text', `"${text}":`, e) + return '' + } +} + +export function parseJsonContentToText(content: JSONContent | string) { + return typeof content === 'string' ? content : richTextToString(content) +} diff --git a/common/src/util/promise.ts b/common/src/util/promise.ts new file mode 100644 index 0000000..4895bf8 --- /dev/null +++ b/common/src/util/promise.ts @@ -0,0 +1,82 @@ +export type RetryPolicy = { + initialBackoffSec: number + retries: number +} + +export const delay = (ms: number) => { + return new Promise((resolve) => setTimeout(() => resolve(), ms)) +} + +export async function withRetries(q: PromiseLike, policy?: RetryPolicy) { + let err: Error | undefined + let delaySec = policy?.initialBackoffSec ?? 5 + const maxRetries = policy?.retries ?? 5 + for (let i = 0; i < maxRetries; i++) { + try { + return await q + } catch (e) { + err = e as Error + if (i < maxRetries) { + console.debug(`Error: ${err.message} - Retrying in ${delaySec}s.`) + await delay(delaySec * 1000) + delaySec *= 2 + } + } + } + throw err +} + +export const mapAsyncChunked = async ( + items: T[], + f: (item: T, index: number) => Promise, + chunkSize = 100 +) => { + const results: U[] = [] + + for (let i = 0; i < items.length; i += chunkSize) { + const chunk = items.slice(i, i + chunkSize) + const chunkResults = await Promise.all( + chunk.map((item, index) => f(item, i + index)) + ) + results.push(...chunkResults) + } + + return results +} + +export const mapAsync = ( + items: T[], + f: (item: T, index: number) => Promise, + maxConcurrentRequests = 100 +) => { + let index = 0 + let currRequests = 0 + const results: U[] = [] + + // The following is a hack to fix a Node bug where the process exits before + // the promise is resolved. + // eslint-disable-next-line @typescript-eslint/no-empty-function + const intervalId = setInterval(() => {}, 10000) + + return new Promise((resolve: (results: U[]) => void, reject) => { + const doWork = () => { + while (index < items.length && currRequests < maxConcurrentRequests) { + const itemIndex = index + f(items[itemIndex], itemIndex) + .then((data) => { + results[itemIndex] = data + currRequests-- + if (index === items.length && currRequests === 0) resolve(results) + else doWork() + }) + .catch(reject) + + index++ + currRequests++ + } + } + + if (items.length === 0) resolve([]) + else doWork() + }).finally(() => clearInterval(intervalId)) +} diff --git a/common/src/util/random.ts b/common/src/util/random.ts new file mode 100644 index 0000000..b9c34cc --- /dev/null +++ b/common/src/util/random.ts @@ -0,0 +1,54 @@ +export const randomString = (length = 12) => + Math.random() + .toString(16) + .substring(2, length + 2) + +export function genHash(str: string) { + // xmur3 + + // Route around compiler bug by using object? + const o = { h: 1779033703 ^ str.length } + + for (let i = 0; i < str.length; i++) { + let h = o.h + h = Math.imul(h ^ str.charCodeAt(i), 3432918353) + h = (h << 13) | (h >>> 19) + o.h = h + } + return function () { + let h = o.h + h = Math.imul(h ^ (h >>> 16), 2246822507) + h = Math.imul(h ^ (h >>> 13), 3266489909) + return (h ^= h >>> 16) >>> 0 + } +} + +export function createRNG(seed: string) { + // https://stackoverflow.com/a/47593316/1592933 + + const gen = genHash(seed) + let [a, b, c, d] = [gen(), gen(), gen(), gen()] + + // sfc32 + return function () { + a >>>= 0 + b >>>= 0 + c >>>= 0 + d >>>= 0 + let t = (a + b) | 0 + a = b ^ (b >>> 9) + b = (c + (c << 3)) | 0 + c = (c << 21) | (c >>> 11) + d = (d + 1) | 0 + t = (t + d) | 0 + c = (c + t) | 0 + return (t >>> 0) / 4294967296 + } +} + +export const shuffle = (array: unknown[], rand: () => number) => { + for (let i = 0; i < array.length; i++) { + const swapIndex = i + Math.floor(rand() * (array.length - i)) + ;[array[i], array[swapIndex]] = [array[swapIndex], array[i]] + } +} \ No newline at end of file diff --git a/common/src/util/slugify.ts b/common/src/util/slugify.ts new file mode 100644 index 0000000..114ae19 --- /dev/null +++ b/common/src/util/slugify.ts @@ -0,0 +1,16 @@ +export const slugify = ( + text: string, + separator = '-', + maxLength = 35 +): string => { + return text + .toString() + .normalize('NFD') // split an accented letter in the base letter and the acent + .replace(/[\u0300-\u036f]/g, '') // remove all previously split accents + .toLowerCase() + .trim() + .replace(/[^a-z0-9 ]/g, '') // remove all chars not letters, numbers and spaces (to be replaced) + .replace(/\s+/g, separator) + .substring(0, maxLength) + .replace(new RegExp(separator + '+$', 'g'), '') // remove terminal separators +} diff --git a/common/src/util/string.ts b/common/src/util/string.ts new file mode 100644 index 0000000..4dae265 --- /dev/null +++ b/common/src/util/string.ts @@ -0,0 +1,7 @@ +export function removeEmojis(str: string) { + return str.replace( + // eslint-disable-next-line no-misleading-character-class + /[\u{1F600}-\u{1F64F}\u{1F300}-\u{1F5FF}\u{1F680}-\u{1F6FF}\u{1F700}-\u{1F77F}\u{1F780}-\u{1F7FF}\u{1F800}-\u{1F8FF}\u{1F900}-\u{1F9FF}\u{1FA00}-\u{1FA6F}\u{1FA70}-\u{1FAFF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{2300}-\u{23FF}\u{2B50}\u{2B55}\u{2934}\u{2935}\u{2B05}\u{2B06}\u{2B07}\u{2B1B}\u{2B1C}\u{2B50}\u{2B55}\u{3030}\u{303D}\u{3297}\u{3299}\u{FE0F}]/gu, + '' + ) +} \ No newline at end of file diff --git a/common/src/util/time.ts b/common/src/util/time.ts new file mode 100644 index 0000000..f6ec89b --- /dev/null +++ b/common/src/util/time.ts @@ -0,0 +1,10 @@ +export const MINUTE_MS = 60 * 1000 +export const HOUR_MS = 60 * MINUTE_MS +export const DAY_MS = 24 * HOUR_MS +export const WEEK_MS = 7 * DAY_MS +export const MONTH_MS = 30 * DAY_MS +export const YEAR_MS = 365 * DAY_MS +export const HOUR_SECONDS = 60 * 60 + +export const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)) diff --git a/common/src/util/tiptap-iframe.ts b/common/src/util/tiptap-iframe.ts new file mode 100644 index 0000000..2fc100e --- /dev/null +++ b/common/src/util/tiptap-iframe.ts @@ -0,0 +1,109 @@ +// Adopted from https://github.com/ueberdosis/tiptap/blob/main/demos/src/Experiments/Embeds/Vue/iframe.ts + +import { Node, mergeAttributes } from '@tiptap/core' + +export interface IframeOptions { + HTMLAttributes: { + [key: string]: any + } +} + +declare module '@tiptap/core' { + interface Commands { + iframe: { + setIframe: (options: { src: string }) => ReturnType + } + } +} + +export default Node.create({ + name: 'iframe', + + group: 'block', + + atom: true, + + addOptions() { + return { + HTMLAttributes: { + class: 'w-full h-80', + height: 80 * 4, + sandbox: + 'allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox', + }, + } + }, + + addAttributes() { + return { + src: { + default: null, + }, + frameBorder: { + default: 0, + }, + } + }, + + parseHTML() { + return [{ tag: 'iframe' }] + }, + + renderHTML({ HTMLAttributes }) { + const iframeAttributes = mergeAttributes( + this.options.HTMLAttributes, + HTMLAttributes + ) + const { src } = HTMLAttributes + + // This is a hack to prevent native from opening the iframe in an in-app browser + // and mobile in another tab. In native, links with target='_blank' open in the in-app browser. + if (src.includes('manifold.markets/embed/')) { + return [ + 'div', + { + style: { + position: 'relative', + }, + ...this.options.HTMLAttributes, + }, + [ + 'a', + { + href: src.replace('embed/', ''), + target: '_self', + style: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + zIndex: 20, // This is equivalent to tailwind's z-20 + display: 'block', + }, + }, + ], + ['iframe', iframeAttributes], + ] + } + + return ['iframe', iframeAttributes] + }, + + addCommands() { + return { + setIframe: + (options: { src: string }) => + ({ tr, dispatch }) => { + const { selection } = tr + const node = this.type.create(options) + + if (dispatch) { + tr.replaceRangeWith(selection.from, selection.to, node) + } + + return true + }, + } + }, +}) diff --git a/common/src/util/try-catch.ts b/common/src/util/try-catch.ts new file mode 100644 index 0000000..7cad9ea --- /dev/null +++ b/common/src/util/try-catch.ts @@ -0,0 +1,7 @@ +export const tryCatch = async (promise: Promise) => { + try { + return { data: await promise, error: null } + } catch (e) { + return { data: null, error: e as E } + } +} diff --git a/common/src/util/types.ts b/common/src/util/types.ts new file mode 100644 index 0000000..09123e8 --- /dev/null +++ b/common/src/util/types.ts @@ -0,0 +1,6 @@ +export function assertUnreachable(x: never, message?: string): never { + if (message) throw new Error(message) + throw new Error( + `Expected unreachable value, instead got: ${JSON.stringify(x)}` + ) +} diff --git a/common/tsconfig.json b/common/tsconfig.json new file mode 100644 index 0000000..2506d8d --- /dev/null +++ b/common/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "rootDir": "src", + "composite": true, + "module": "commonjs", + "moduleResolution": "node", + "noImplicitReturns": true, + "outDir": "lib", + "tsBuildInfoFile": "lib/tsconfig.tsbuildinfo", + "sourceMap": true, + "strict": true, + "target": "es2022", + "skipLibCheck": true, + "paths": { + "common/*": ["./src/*", "../lib/*"] + } + }, + "include": ["src/**/*.ts"] +} diff --git a/common/yarn.lock b/common/yarn.lock new file mode 100644 index 0000000..371c902 --- /dev/null +++ b/common/yarn.lock @@ -0,0 +1,2704 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@ampproject/remapping@^2.2.0": + version "2.3.0" + resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.3.0.tgz#ed441b6fa600072520ce18b43d2c8cc8caecc7f4" + integrity sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw== + dependencies: + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.24" + +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.23.5", "@babel/code-frame@^7.24.2": + version "7.24.2" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.24.2.tgz#718b4b19841809a58b29b68cde80bc5e1aa6d9ae" + integrity sha512-y5+tLQyV8pg3fsiln67BVLD1P13Eg4lh5RW9mF0zUuvLrv9uIQ4MCL+CRT+FTsBlBjcIan6PGsLcBN0m3ClUyQ== + dependencies: + "@babel/highlight" "^7.24.2" + picocolors "^1.0.0" + +"@babel/compat-data@^7.23.5": + version "7.24.4" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.24.4.tgz#6f102372e9094f25d908ca0d34fc74c74606059a" + integrity sha512-vg8Gih2MLK+kOkHJp4gBEIkyaIi00jgWot2D9QOmmfLC8jINSOzmCLta6Bvz/JSBCqnegV0L80jhxkol5GWNfQ== + +"@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.23.9": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.24.5.tgz#15ab5b98e101972d171aeef92ac70d8d6718f06a" + integrity sha512-tVQRucExLQ02Boi4vdPp49svNGcfL2GhdTCT9aldhXgCJVAI21EtRfBettiuLUwce/7r6bFdgs6JFkcdTiFttA== + dependencies: + "@ampproject/remapping" "^2.2.0" + "@babel/code-frame" "^7.24.2" + "@babel/generator" "^7.24.5" + "@babel/helper-compilation-targets" "^7.23.6" + "@babel/helper-module-transforms" "^7.24.5" + "@babel/helpers" "^7.24.5" + "@babel/parser" "^7.24.5" + "@babel/template" "^7.24.0" + "@babel/traverse" "^7.24.5" + "@babel/types" "^7.24.5" + convert-source-map "^2.0.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.2.3" + semver "^6.3.1" + +"@babel/generator@^7.24.5", "@babel/generator@^7.7.2": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.24.5.tgz#e5afc068f932f05616b66713e28d0f04e99daeb3" + integrity sha512-x32i4hEXvr+iI0NEoEfDKzlemF8AmtOP8CcrRaEcpzysWuoEb1KknpcvMsHKPONoKZiDuItklgWhB18xEhr9PA== + dependencies: + "@babel/types" "^7.24.5" + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.25" + jsesc "^2.5.1" + +"@babel/helper-compilation-targets@^7.23.6": + version "7.23.6" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz#4d79069b16cbcf1461289eccfbbd81501ae39991" + integrity sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ== + dependencies: + "@babel/compat-data" "^7.23.5" + "@babel/helper-validator-option" "^7.23.5" + browserslist "^4.22.2" + lru-cache "^5.1.1" + semver "^6.3.1" + +"@babel/helper-environment-visitor@^7.22.20": + version "7.22.20" + resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz#96159db61d34a29dba454c959f5ae4a649ba9167" + integrity sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA== + +"@babel/helper-function-name@^7.23.0": + version "7.23.0" + resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz#1f9a3cdbd5b2698a670c30d2735f9af95ed52759" + integrity sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw== + dependencies: + "@babel/template" "^7.22.15" + "@babel/types" "^7.23.0" + +"@babel/helper-hoist-variables@^7.22.5": + version "7.22.5" + resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz#c01a007dac05c085914e8fb652b339db50d823bb" + integrity sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw== + dependencies: + "@babel/types" "^7.22.5" + +"@babel/helper-module-imports@^7.24.3": + version "7.24.3" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.24.3.tgz#6ac476e6d168c7c23ff3ba3cf4f7841d46ac8128" + integrity sha512-viKb0F9f2s0BCS22QSF308z/+1YWKV/76mwt61NBzS5izMzDPwdq1pTrzf+Li3npBWX9KdQbkeCt1jSAM7lZqg== + dependencies: + "@babel/types" "^7.24.0" + +"@babel/helper-module-transforms@^7.24.5": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.24.5.tgz#ea6c5e33f7b262a0ae762fd5986355c45f54a545" + integrity sha512-9GxeY8c2d2mdQUP1Dye0ks3VDyIMS98kt/llQ2nUId8IsWqTF0l1LkSX0/uP7l7MCDrzXS009Hyhe2gzTiGW8A== + dependencies: + "@babel/helper-environment-visitor" "^7.22.20" + "@babel/helper-module-imports" "^7.24.3" + "@babel/helper-simple-access" "^7.24.5" + "@babel/helper-split-export-declaration" "^7.24.5" + "@babel/helper-validator-identifier" "^7.24.5" + +"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.24.0", "@babel/helper-plugin-utils@^7.8.0": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.5.tgz#a924607dd254a65695e5bd209b98b902b3b2f11a" + integrity sha512-xjNLDopRzW2o6ba0gKbkZq5YWEBaK3PCyTOY1K2P/O07LGMhMqlMXPxwN4S5/RhWuCobT8z0jrlKGlYmeR1OhQ== + +"@babel/helper-simple-access@^7.24.5": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.24.5.tgz#50da5b72f58c16b07fbd992810be6049478e85ba" + integrity sha512-uH3Hmf5q5n7n8mz7arjUlDOCbttY/DW4DYhE6FUsjKJ/oYC1kQQUvwEQWxRwUpX9qQKRXeqLwWxrqilMrf32sQ== + dependencies: + "@babel/types" "^7.24.5" + +"@babel/helper-split-export-declaration@^7.24.5": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.5.tgz#b9a67f06a46b0b339323617c8c6213b9055a78b6" + integrity sha512-5CHncttXohrHk8GWOFCcCl4oRD9fKosWlIRgWm4ql9VYioKm52Mk2xsmoohvm7f3JoiLSM5ZgJuRaf5QZZYd3Q== + dependencies: + "@babel/types" "^7.24.5" + +"@babel/helper-string-parser@^7.24.1": + version "7.24.1" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.24.1.tgz#f99c36d3593db9540705d0739a1f10b5e20c696e" + integrity sha512-2ofRCjnnA9y+wk8b9IAREroeUP02KHp431N2mhKniy2yKIDKpbrHv9eXwm8cBeWQYcJmzv5qKCu65P47eCF7CQ== + +"@babel/helper-validator-identifier@^7.24.5": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.5.tgz#918b1a7fa23056603506370089bd990d8720db62" + integrity sha512-3q93SSKX2TWCG30M2G2kwaKeTYgEUp5Snjuj8qm729SObL6nbtUldAi37qbxkD5gg3xnBio+f9nqpSepGZMvxA== + +"@babel/helper-validator-option@^7.23.5": + version "7.23.5" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz#907a3fbd4523426285365d1206c423c4c5520307" + integrity sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw== + +"@babel/helpers@^7.24.5": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.24.5.tgz#fedeb87eeafa62b621160402181ad8585a22a40a" + integrity sha512-CiQmBMMpMQHwM5m01YnrM6imUG1ebgYJ+fAIW4FZe6m4qHTPaRHti+R8cggAwkdz4oXhtO4/K9JWlh+8hIfR2Q== + dependencies: + "@babel/template" "^7.24.0" + "@babel/traverse" "^7.24.5" + "@babel/types" "^7.24.5" + +"@babel/highlight@^7.24.2": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.24.5.tgz#bc0613f98e1dd0720e99b2a9ee3760194a704b6e" + integrity sha512-8lLmua6AVh/8SLJRRVD6V8p73Hir9w5mJrhE+IPpILG31KKlI9iz5zmBYKcWPS59qSfgP9RaSBQSHHE81WKuEw== + dependencies: + "@babel/helper-validator-identifier" "^7.24.5" + chalk "^2.4.2" + js-tokens "^4.0.0" + picocolors "^1.0.0" + +"@babel/parser@^7.1.0", "@babel/parser@^7.14.7", "@babel/parser@^7.20.7", "@babel/parser@^7.23.9", "@babel/parser@^7.24.0", "@babel/parser@^7.24.5": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.24.5.tgz#4a4d5ab4315579e5398a82dcf636ca80c3392790" + integrity sha512-EOv5IK8arwh3LI47dz1b0tKUb/1uhHAnHJOrjgtQMIpu1uXd9mlFrJg9IUgGUgZ41Ch0K8REPTYpO7B76b4vJg== + +"@babel/plugin-syntax-async-generators@^7.8.4": + version "7.8.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d" + integrity sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-bigint@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz#4c9a6f669f5d0cdf1b90a1671e9a146be5300cea" + integrity sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-class-properties@^7.8.3": + version "7.12.13" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz#b5c987274c4a3a82b89714796931a6b53544ae10" + integrity sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA== + dependencies: + "@babel/helper-plugin-utils" "^7.12.13" + +"@babel/plugin-syntax-import-meta@^7.8.3": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz#ee601348c370fa334d2207be158777496521fd51" + integrity sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-json-strings@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz#01ca21b668cd8218c9e640cb6dd88c5412b2c96a" + integrity sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-jsx@^7.7.2": + version "7.24.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.24.1.tgz#3f6ca04b8c841811dbc3c5c5f837934e0d626c10" + integrity sha512-2eCtxZXf+kbkMIsXS4poTvT4Yu5rXiRa+9xGVT56raghjmBTKMpFNc9R4IDiB4emao9eO22Ox7CxuJG7BgExqA== + dependencies: + "@babel/helper-plugin-utils" "^7.24.0" + +"@babel/plugin-syntax-logical-assignment-operators@^7.8.3": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz#ca91ef46303530448b906652bac2e9fe9941f699" + integrity sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-nullish-coalescing-operator@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz#167ed70368886081f74b5c36c65a88c03b66d1a9" + integrity sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-numeric-separator@^7.8.3": + version "7.10.4" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz#b9b070b3e33570cd9fd07ba7fa91c0dd37b9af97" + integrity sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug== + dependencies: + "@babel/helper-plugin-utils" "^7.10.4" + +"@babel/plugin-syntax-object-rest-spread@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz#60e225edcbd98a640332a2e72dd3e66f1af55871" + integrity sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-optional-catch-binding@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz#6111a265bcfb020eb9efd0fdfd7d26402b9ed6c1" + integrity sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-optional-chaining@^7.8.3": + version "7.8.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz#4f69c2ab95167e0180cd5336613f8c5788f7d48a" + integrity sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg== + dependencies: + "@babel/helper-plugin-utils" "^7.8.0" + +"@babel/plugin-syntax-top-level-await@^7.8.3": + version "7.14.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz#c1cfdadc35a646240001f06138247b741c34d94c" + integrity sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw== + dependencies: + "@babel/helper-plugin-utils" "^7.14.5" + +"@babel/plugin-syntax-typescript@^7.7.2": + version "7.24.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.24.1.tgz#b3bcc51f396d15f3591683f90239de143c076844" + integrity sha512-Yhnmvy5HZEnHUty6i++gcfH1/l68AHnItFHnaCv6hn9dNh0hQvvQJsxpi4BMBFN5DLeHBuucT/0DgzXif/OyRw== + dependencies: + "@babel/helper-plugin-utils" "^7.24.0" + +"@babel/template@^7.22.15", "@babel/template@^7.24.0", "@babel/template@^7.3.3": + version "7.24.0" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.24.0.tgz#c6a524aa93a4a05d66aaf31654258fae69d87d50" + integrity sha512-Bkf2q8lMB0AFpX0NFEqSbx1OkTHf0f+0j82mkw+ZpzBnkk7e9Ql0891vlfgi+kHwOk8tQjiQHpqh4LaSa0fKEA== + dependencies: + "@babel/code-frame" "^7.23.5" + "@babel/parser" "^7.24.0" + "@babel/types" "^7.24.0" + +"@babel/traverse@^7.24.5": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.24.5.tgz#972aa0bc45f16983bf64aa1f877b2dd0eea7e6f8" + integrity sha512-7aaBLeDQ4zYcUFDUD41lJc1fG8+5IU9DaNSJAgal866FGvmD5EbWQgnEC6kO1gGLsX0esNkfnJSndbTXA3r7UA== + dependencies: + "@babel/code-frame" "^7.24.2" + "@babel/generator" "^7.24.5" + "@babel/helper-environment-visitor" "^7.22.20" + "@babel/helper-function-name" "^7.23.0" + "@babel/helper-hoist-variables" "^7.22.5" + "@babel/helper-split-export-declaration" "^7.24.5" + "@babel/parser" "^7.24.5" + "@babel/types" "^7.24.5" + debug "^4.3.1" + globals "^11.1.0" + +"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.22.5", "@babel/types@^7.23.0", "@babel/types@^7.24.0", "@babel/types@^7.24.5", "@babel/types@^7.3.3": + version "7.24.5" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.24.5.tgz#7661930afc638a5383eb0c4aee59b74f38db84d7" + integrity sha512-6mQNsaLeXTw0nxYUYu+NSa4Hx4BlF1x1x8/PMFbiR+GBSr+2DkECc69b8hgy2frEodNcvPffeH8YfWd3LI6jhQ== + dependencies: + "@babel/helper-string-parser" "^7.24.1" + "@babel/helper-validator-identifier" "^7.24.5" + to-fast-properties "^2.0.0" + +"@bcoe/v8-coverage@^0.2.3": + version "0.2.3" + resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" + integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== + +"@istanbuljs/load-nyc-config@^1.0.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" + integrity sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ== + dependencies: + camelcase "^5.3.1" + find-up "^4.1.0" + get-package-type "^0.1.0" + js-yaml "^3.13.1" + resolve-from "^5.0.0" + +"@istanbuljs/schema@^0.1.2", "@istanbuljs/schema@^0.1.3": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" + integrity sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA== + +"@jest/console@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/console/-/console-29.7.0.tgz#cd4822dbdb84529265c5a2bdb529a3c9cc950ffc" + integrity sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg== + dependencies: + "@jest/types" "^29.6.3" + "@types/node" "*" + chalk "^4.0.0" + jest-message-util "^29.7.0" + jest-util "^29.7.0" + slash "^3.0.0" + +"@jest/core@^29.3.1", "@jest/core@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/core/-/core-29.7.0.tgz#b6cccc239f30ff36609658c5a5e2291757ce448f" + integrity sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg== + dependencies: + "@jest/console" "^29.7.0" + "@jest/reporters" "^29.7.0" + "@jest/test-result" "^29.7.0" + "@jest/transform" "^29.7.0" + "@jest/types" "^29.6.3" + "@types/node" "*" + ansi-escapes "^4.2.1" + chalk "^4.0.0" + ci-info "^3.2.0" + exit "^0.1.2" + graceful-fs "^4.2.9" + jest-changed-files "^29.7.0" + jest-config "^29.7.0" + jest-haste-map "^29.7.0" + jest-message-util "^29.7.0" + jest-regex-util "^29.6.3" + jest-resolve "^29.7.0" + jest-resolve-dependencies "^29.7.0" + jest-runner "^29.7.0" + jest-runtime "^29.7.0" + jest-snapshot "^29.7.0" + jest-util "^29.7.0" + jest-validate "^29.7.0" + jest-watcher "^29.7.0" + micromatch "^4.0.4" + pretty-format "^29.7.0" + slash "^3.0.0" + strip-ansi "^6.0.0" + +"@jest/environment@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/environment/-/environment-29.7.0.tgz#24d61f54ff1f786f3cd4073b4b94416383baf2a7" + integrity sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw== + dependencies: + "@jest/fake-timers" "^29.7.0" + "@jest/types" "^29.6.3" + "@types/node" "*" + jest-mock "^29.7.0" + +"@jest/expect-utils@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/expect-utils/-/expect-utils-29.7.0.tgz#023efe5d26a8a70f21677d0a1afc0f0a44e3a1c6" + integrity sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA== + dependencies: + jest-get-type "^29.6.3" + +"@jest/expect@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/expect/-/expect-29.7.0.tgz#76a3edb0cb753b70dfbfe23283510d3d45432bf2" + integrity sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ== + dependencies: + expect "^29.7.0" + jest-snapshot "^29.7.0" + +"@jest/fake-timers@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/fake-timers/-/fake-timers-29.7.0.tgz#fd91bf1fffb16d7d0d24a426ab1a47a49881a565" + integrity sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ== + dependencies: + "@jest/types" "^29.6.3" + "@sinonjs/fake-timers" "^10.0.2" + "@types/node" "*" + jest-message-util "^29.7.0" + jest-mock "^29.7.0" + jest-util "^29.7.0" + +"@jest/globals@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/globals/-/globals-29.7.0.tgz#8d9290f9ec47ff772607fa864ca1d5a2efae1d4d" + integrity sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ== + dependencies: + "@jest/environment" "^29.7.0" + "@jest/expect" "^29.7.0" + "@jest/types" "^29.6.3" + jest-mock "^29.7.0" + +"@jest/reporters@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/reporters/-/reporters-29.7.0.tgz#04b262ecb3b8faa83b0b3d321623972393e8f4c7" + integrity sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg== + dependencies: + "@bcoe/v8-coverage" "^0.2.3" + "@jest/console" "^29.7.0" + "@jest/test-result" "^29.7.0" + "@jest/transform" "^29.7.0" + "@jest/types" "^29.6.3" + "@jridgewell/trace-mapping" "^0.3.18" + "@types/node" "*" + chalk "^4.0.0" + collect-v8-coverage "^1.0.0" + exit "^0.1.2" + glob "^7.1.3" + graceful-fs "^4.2.9" + istanbul-lib-coverage "^3.0.0" + istanbul-lib-instrument "^6.0.0" + istanbul-lib-report "^3.0.0" + istanbul-lib-source-maps "^4.0.0" + istanbul-reports "^3.1.3" + jest-message-util "^29.7.0" + jest-util "^29.7.0" + jest-worker "^29.7.0" + slash "^3.0.0" + string-length "^4.0.1" + strip-ansi "^6.0.0" + v8-to-istanbul "^9.0.1" + +"@jest/schemas@^29.6.3": + version "29.6.3" + resolved "https://registry.yarnpkg.com/@jest/schemas/-/schemas-29.6.3.tgz#430b5ce8a4e0044a7e3819663305a7b3091c8e03" + integrity sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA== + dependencies: + "@sinclair/typebox" "^0.27.8" + +"@jest/source-map@^29.6.3": + version "29.6.3" + resolved "https://registry.yarnpkg.com/@jest/source-map/-/source-map-29.6.3.tgz#d90ba772095cf37a34a5eb9413f1b562a08554c4" + integrity sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw== + dependencies: + "@jridgewell/trace-mapping" "^0.3.18" + callsites "^3.0.0" + graceful-fs "^4.2.9" + +"@jest/test-result@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/test-result/-/test-result-29.7.0.tgz#8db9a80aa1a097bb2262572686734baed9b1657c" + integrity sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA== + dependencies: + "@jest/console" "^29.7.0" + "@jest/types" "^29.6.3" + "@types/istanbul-lib-coverage" "^2.0.0" + collect-v8-coverage "^1.0.0" + +"@jest/test-sequencer@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz#6cef977ce1d39834a3aea887a1726628a6f072ce" + integrity sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw== + dependencies: + "@jest/test-result" "^29.7.0" + graceful-fs "^4.2.9" + jest-haste-map "^29.7.0" + slash "^3.0.0" + +"@jest/transform@^29.7.0": + version "29.7.0" + resolved "https://registry.yarnpkg.com/@jest/transform/-/transform-29.7.0.tgz#df2dd9c346c7d7768b8a06639994640c642e284c" + integrity sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw== + dependencies: + "@babel/core" "^7.11.6" + "@jest/types" "^29.6.3" + "@jridgewell/trace-mapping" "^0.3.18" + babel-plugin-istanbul "^6.1.1" + chalk "^4.0.0" + convert-source-map "^2.0.0" + fast-json-stable-stringify "^2.1.0" + graceful-fs "^4.2.9" + jest-haste-map "^29.7.0" + jest-regex-util "^29.6.3" + jest-util "^29.7.0" + micromatch "^4.0.4" + pirates "^4.0.4" + slash "^3.0.0" + write-file-atomic "^4.0.2" + +"@jest/types@^29.3.1", "@jest/types@^29.6.3": + version "29.6.3" + resolved "https://registry.yarnpkg.com/@jest/types/-/types-29.6.3.tgz#1131f8cf634e7e84c5e77bab12f052af585fba59" + integrity sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw== + dependencies: + "@jest/schemas" "^29.6.3" + "@types/istanbul-lib-coverage" "^2.0.0" + "@types/istanbul-reports" "^3.0.0" + "@types/node" "*" + "@types/yargs" "^17.0.8" + chalk "^4.0.0" + +"@jridgewell/gen-mapping@^0.3.5": + version "0.3.5" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz#dcce6aff74bdf6dad1a95802b69b04a2fcb1fb36" + integrity sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg== + dependencies: + "@jridgewell/set-array" "^1.2.1" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/resolve-uri@^3.1.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + +"@jridgewell/set-array@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.2.1.tgz#558fb6472ed16a4c850b889530e6b36438c49280" + integrity sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A== + +"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14": + version "1.4.15" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32" + integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg== + +"@jridgewell/trace-mapping@^0.3.12", "@jridgewell/trace-mapping@^0.3.18", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": + version "0.3.25" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" + integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + +"@sinclair/typebox@^0.27.8": + version "0.27.8" + resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.8.tgz#6667fac16c436b5434a387a34dedb013198f6e6e" + integrity sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA== + +"@sinonjs/commons@^3.0.0": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-3.0.1.tgz#1029357e44ca901a615585f6d27738dbc89084cd" + integrity sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ== + dependencies: + type-detect "4.0.8" + +"@sinonjs/fake-timers@^10.0.2": + version "10.3.0" + resolved "https://registry.yarnpkg.com/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz#55fdff1ecab9f354019129daf4df0dd4d923ea66" + integrity sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA== + dependencies: + "@sinonjs/commons" "^3.0.0" + +"@supabase/functions-js@^2.1.5": + version "2.4.1" + resolved "https://registry.yarnpkg.com/@supabase/functions-js/-/functions-js-2.4.1.tgz#373e75f8d3453bacd71fb64f88d7a341d7b53ad7" + integrity sha512-8sZ2ibwHlf+WkHDUZJUXqqmPvWQ3UHN0W30behOJngVh/qHHekhJLCFbh0AjkE9/FqqXtf9eoVvmYgfCLk5tNA== + dependencies: + "@supabase/node-fetch" "^2.6.14" + +"@supabase/gotrue-js@^2.56.0": + version "2.62.2" + resolved "https://registry.yarnpkg.com/@supabase/gotrue-js/-/gotrue-js-2.62.2.tgz#9f15a451559d71475c953aa0027e1248b0210196" + integrity sha512-AP6e6W9rQXFTEJ7sTTNYQrNf0LCcnt1hUW+RIgUK+Uh3jbWvcIST7wAlYyNZiMlS9+PYyymWQ+Ykz/rOYSO0+A== + dependencies: + "@supabase/node-fetch" "^2.6.14" + +"@supabase/node-fetch@^2.6.14": + version "2.6.15" + resolved "https://registry.yarnpkg.com/@supabase/node-fetch/-/node-fetch-2.6.15.tgz#731271430e276983191930816303c44159e7226c" + integrity sha512-1ibVeYUacxWYi9i0cf5efil6adJ9WRyZBLivgjs+AUpewx1F3xPi7gLgaASI2SmIQxPoCEjAsLAzKPgMJVgOUQ== + dependencies: + whatwg-url "^5.0.0" + +"@supabase/postgrest-js@^1.8.6": + version "1.15.2" + resolved "https://registry.yarnpkg.com/@supabase/postgrest-js/-/postgrest-js-1.15.2.tgz#c0a725706e3d534570d014d7b713cea12553ab98" + integrity sha512-9/7pUmXExvGuEK1yZhVYXPZnLEkDTwxgMQHXLrN5BwPZZm4iUCL1YEyep/Z2lIZah8d8M433mVAUEGsihUj5KQ== + dependencies: + "@supabase/node-fetch" "^2.6.14" + +"@supabase/realtime-js@^2.8.4": + version "2.9.5" + resolved "https://registry.yarnpkg.com/@supabase/realtime-js/-/realtime-js-2.9.5.tgz#22b7de952a7f37868ffc25d32d19f03f27bfcb40" + integrity sha512-TEHlGwNGGmKPdeMtca1lFTYCedrhTAv3nZVoSjrKQ+wkMmaERuCe57zkC5KSWFzLYkb5FVHW8Hrr+PX1DDwplQ== + dependencies: + "@supabase/node-fetch" "^2.6.14" + "@types/phoenix" "^1.5.4" + "@types/ws" "^8.5.10" + ws "^8.14.2" + +"@supabase/storage-js@^2.5.4": + version "2.5.5" + resolved "https://registry.yarnpkg.com/@supabase/storage-js/-/storage-js-2.5.5.tgz#2958e2a2cec8440e605bb53bd36649288c4dfa01" + integrity sha512-OpLoDRjFwClwc2cjTJZG8XviTiQH4Ik8sCiMK5v7et0MDu2QlXjCAW3ljxJB5+z/KazdMOTnySi+hysxWUPu3w== + dependencies: + "@supabase/node-fetch" "^2.6.14" + +"@supabase/supabase-js@2.38.5": + version "2.38.5" + resolved "https://registry.yarnpkg.com/@supabase/supabase-js/-/supabase-js-2.38.5.tgz#4341c09288b9ba0ebb34862519d4ec4fb028e18e" + integrity sha512-QTXld3AfwAJgeOGyOKsCcT7AjC3jJxN02iHy299Fw+qKX0lJ1tVVhMGlga101C1stUCvgzjcypmMSGiZ2oeKsw== + dependencies: + "@supabase/functions-js" "^2.1.5" + "@supabase/gotrue-js" "^2.56.0" + "@supabase/node-fetch" "^2.6.14" + "@supabase/postgrest-js" "^1.8.6" + "@supabase/realtime-js" "^2.8.4" + "@supabase/storage-js" "^2.5.4" + +"@tiptap/core@2.0.0-beta.204": + version "2.0.0-beta.204" + resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-2.0.0-beta.204.tgz#ec37e333718ed21b399e394cea06b7ab4653bbd3" + integrity sha512-MH4LQE6rvX+DAy83tZH5E6gaA/hO5A6F/w5ZM6En5PcRhNsgpfQl+kjRfeVQYahxouc1mzetayhRe4XQ8PAwng== + dependencies: + prosemirror-commands "^1.3.1" + prosemirror-keymap "^1.2.0" + prosemirror-model "^1.18.1" + prosemirror-schema-list "^1.2.2" + prosemirror-state "^1.4.1" + prosemirror-transform "^1.7.0" + prosemirror-view "^1.28.2" + +"@tiptap/core@^2.0.0-beta.204": + version "2.3.2" + resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-2.3.2.tgz#729e7f0efd199406e580f8edf15fa5744991a52b" + integrity sha512-4sMpzYuxiG+fYMwPRXy+mLRVU315KEqzQUcBc2FEgSsmw9Kionykmkq3DvEco7rH8r0NdV/l9R49wVEtX54VqQ== + +"@tiptap/extension-blockquote@^2.0.0-beta.204": + version "2.3.2" + resolved "https://registry.yarnpkg.com/@tiptap/extension-blockquote/-/extension-blockquote-2.3.2.tgz#9c0cc3064fb0b33253469587bd57acb3847a583d" + integrity sha512-dyXx1hHAW/0BSxCUNWcxc8UN+s0wRTdtH46u6IEf91z+IOWjJwmSxT00+UMYh6hdOYDDsJYxPe9gcuSWYCIkCg== + +"@tiptap/extension-bold@^2.0.0-beta.204": + version "2.3.2" + resolved "https://registry.yarnpkg.com/@tiptap/extension-bold/-/extension-bold-2.3.2.tgz#ebe13406db8e7d906ad80ad7640b2e80dd6e92ce" + integrity sha512-Mdc0qOPeJxxt5kSYKpNs7TzbQHeVpbpxwafUrxrvfD2iOnJlwlNxVWsVulc1t5EA8NpbTqYJTPmAtv2h/qmsfw== + +"@tiptap/extension-bullet-list@^2.0.0-beta.204": + version "2.3.2" + resolved "https://registry.yarnpkg.com/@tiptap/extension-bullet-list/-/extension-bullet-list-2.3.2.tgz#f15a8ebef88db2542bb476064f10d1f17788ff5e" + integrity sha512-nzvXSGxJuuZdQ6NE0gJ2GC+0gjXZTgU2+Z8TEKi7TYLUAjAoiU1Iniz1XA97cuFwVrNKp031IF1LivK085NqQA== + +"@tiptap/extension-code-block@^2.0.0-beta.204": + version "2.3.2" + resolved "https://registry.yarnpkg.com/@tiptap/extension-code-block/-/extension-code-block-2.3.2.tgz#fe7d3a5a9c1977579dd613e5555e3d32119961b0" + integrity sha512-Ng5dh8+FMD3pxaqZEDSRxTjgjPCNdEEVUTJnuljZXQ9ZxI9wVsKsGs53Hunpita4Qgk0DYhlfAvGUKCM0nCH4A== + +"@tiptap/extension-code@^2.0.0-beta.204": + version "2.3.2" + resolved "https://registry.yarnpkg.com/@tiptap/extension-code/-/extension-code-2.3.2.tgz#36689994b76550e068ca9cc29cc8721e441bf2b5" + integrity sha512-LyIRBFJCxbgi96ejoeewESvfUf5igfngamZJK+uegfTcznimP0AjSWs3whJwZ9QXUsQrB9tIrWIG4GBtatp6qw== + +"@tiptap/extension-document@^2.0.0-beta.204": + version "2.3.2" + resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-2.3.2.tgz#8914f952c946d150398913f1801295f101ded179" + integrity sha512-EQcfkvA7lkZPKllhGo2jiEYLJyXhBFK7++oRatgbfgHEJ2uLBGv6ys7WLCeRA/ntcaWTH3rlS+HR/Y8/nnyQYg== + +"@tiptap/extension-dropcursor@^2.0.0-beta.204": + version "2.3.2" + resolved "https://registry.yarnpkg.com/@tiptap/extension-dropcursor/-/extension-dropcursor-2.3.2.tgz#3f12430fb5fa7e436726c66c53fe0334f97e1834" + integrity sha512-r7JJn9dEnIRDdbnTCAUFCWX4OPsR48+4OEm5eGlysEaD2h4z0G1AaK5XXwOoQhP3WP2LHHjL4LahlYZvltzFzw== + +"@tiptap/extension-gapcursor@^2.0.0-beta.204": + version "2.3.2" + resolved "https://registry.yarnpkg.com/@tiptap/extension-gapcursor/-/extension-gapcursor-2.3.2.tgz#e16256c5fa46dd62d65357cf8c62482b0f183dac" + integrity sha512-PSry4JHUIOhXytvYUQGtYgfIKCIhnmbKksZ8/CfCaKgGJpjOpnzqRG5FnYXZB7NiqouABreM7+IgkH0mOLq6HQ== + +"@tiptap/extension-hard-break@^2.0.0-beta.204": + version "2.3.2" + resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-2.3.2.tgz#1ad683a63c7760cdb075733cdb035d18328c8042" + integrity sha512-Oy/Dj75kw/tyNyrcFf97r872NZggISfvabTptH8j1gFPg/XzT5ERcT2fvgpbsBx0WWlXOaFkC1owta6kS6MZpg== + +"@tiptap/extension-heading@^2.0.0-beta.204": + version "2.3.2" + resolved "https://registry.yarnpkg.com/@tiptap/extension-heading/-/extension-heading-2.3.2.tgz#861700e0ee5200568ab0ca07705d41b9998f5291" + integrity sha512-KBew4QCnYASBPEJlZ4vKQnm4R9B206H8kE5+hq8OOyF3FVkR6FgF/AbY/E/4/+2blx82PGp+9gvPUVpEv36ifQ== + +"@tiptap/extension-history@^2.0.0-beta.204": + version "2.3.2" + resolved "https://registry.yarnpkg.com/@tiptap/extension-history/-/extension-history-2.3.2.tgz#6c36093287fd8bfc2b2970b438ece9ffa8805c00" + integrity sha512-LTon7ys+C6wLmN/nXYkr1pDxIiIv0Czn4US7I/1b8Ws2N6PU+nMm4r7Uj8hKrDYL8yPQUaS4gIs1hhOwJ8UjtA== + +"@tiptap/extension-horizontal-rule@^2.0.0-beta.204": + version "2.3.2" + resolved "https://registry.yarnpkg.com/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.3.2.tgz#04da4de4b5006203b8a4e4d354ec2788df30b0f2" + integrity sha512-nz4GcYvZmJOX20GAjR5ymZgzQCbhnK/rmcunQf4zkl4LA5sXm70P70I9bDtrT/mgmz5dnBUTkVAkLTtKbovdDQ== + +"@tiptap/extension-image@2.0.0-beta.204": + version "2.0.0-beta.204" + resolved "https://registry.yarnpkg.com/@tiptap/extension-image/-/extension-image-2.0.0-beta.204.tgz#8f4905fe66eb58dc44364cc48eba5e2b237933dc" + integrity sha512-VcWMNmrFDSFm/GF8ITr6wgieMgfvi3Ay4b2ZodY2lfHhpZ2llTg67XzrgjGMXDbdvB+P6pG8VnQzn8/ETSUHpg== + +"@tiptap/extension-italic@^2.0.0-beta.204": + version "2.3.2" + resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-2.3.2.tgz#d68977f19aac0d87daf1ce831caf2217a569a61c" + integrity sha512-6RJmexu/E+JP2+lhzJLV+5KZJiTrJE+p/hnDk13CBK2VgiwcJYmcZSVk+Yk6Suwrb1qTAosu8paKIwVJa/VMUg== + +"@tiptap/extension-link@2.0.0-beta.204": + version "2.0.0-beta.204" + resolved "https://registry.yarnpkg.com/@tiptap/extension-link/-/extension-link-2.0.0-beta.204.tgz#d2282876d36d4d548409519eac9dfd52fd5e6bae" + integrity sha512-maHG0otu8cO6dsDlloid23t2W5bYsB06t2Tz6snaaWuOEeYyX76XfMXwI6HVSwRbrJi/G+WX41WiKV9ShFPfrg== + dependencies: + linkifyjs "^3.0.5" + prosemirror-model "^1.18.1" + prosemirror-state "^1.4.1" + +"@tiptap/extension-list-item@^2.0.0-beta.204": + version "2.3.2" + resolved "https://registry.yarnpkg.com/@tiptap/extension-list-item/-/extension-list-item-2.3.2.tgz#7f27bd9081194597c9af9c294b087705b75dd0e4" + integrity sha512-vgT7tkSZd99xAEph9quPlVdRkgPU4GJp9K7bNS8Y7GnSLU0KkDHbtDpb0pyz76HVpeOnt/QGmtqF14Il9T2IPQ== + +"@tiptap/extension-mention@2.0.0-beta.204": + version "2.0.0-beta.204" + resolved "https://registry.yarnpkg.com/@tiptap/extension-mention/-/extension-mention-2.0.0-beta.204.tgz#f7c2af99412809bae2cecb592020d973dcbeadca" + integrity sha512-OROZ9xi5TdRsDgJB6j9lf9gPxWy+0iQaZSWaqiI7V9wtw8FyYgUz4TAmMOa2ZfRotwcfNWdtLZi7AZxDDb/Fig== + dependencies: + prosemirror-model "^1.18.1" + prosemirror-state "^1.4.1" + +"@tiptap/extension-ordered-list@^2.0.0-beta.204": + version "2.3.2" + resolved "https://registry.yarnpkg.com/@tiptap/extension-ordered-list/-/extension-ordered-list-2.3.2.tgz#b7c052ec6f7fc6308198fb73f1a06c0daabd3b30" + integrity sha512-eMnQDgWpaQ3sdlFg1M85oziFYl2h/GRBjUt4JhF5kyWpHOYDj1/bX1fndZOBQ5xaoNlbcaeEkIc53xVX4ZV9tw== + +"@tiptap/extension-paragraph@^2.0.0-beta.204": + version "2.3.2" + resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-2.3.2.tgz#2141b3fcf3ca74cadf8703c07582585407de723d" + integrity sha512-bKzL4NXp0pDM/Q5ZCpjLxjQU4DwoWc6CDww1M4B4dp1sfiXiE2P7EOCMM2TfJOqNPUFpp5RcFKKcxC2Suj8W4w== + +"@tiptap/extension-strike@^2.0.0-beta.204": + version "2.3.2" + resolved "https://registry.yarnpkg.com/@tiptap/extension-strike/-/extension-strike-2.3.2.tgz#44d7e5eac5a9dc8969895b85e4e37546d0e9b140" + integrity sha512-gi16YtLnXKPubxafvcGSAELac4i8S6Eb9Av0AaH6QH9H9zzSHN7qOrX930Tp2Pod5a/a82kk7kN7IB6htAeaYA== + +"@tiptap/extension-text@^2.0.0-beta.204": + version "2.3.2" + resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-2.3.2.tgz#000c1c4d5ae21b8ddef777b8298b67260e13274f" + integrity sha512-a3whwDyyOsrmOQbfeY+Fm5XypSRgT3IGqWgz0r4U7oko57/X6Env08F1Ie2e2UkQw9B1MoW9cm3dC6jvrdzzYA== + +"@tiptap/starter-kit@2.0.0-beta.204": + version "2.0.0-beta.204" + resolved "https://registry.yarnpkg.com/@tiptap/starter-kit/-/starter-kit-2.0.0-beta.204.tgz#12fb0701d7c37f022b94042f430f1518beeea072" + integrity sha512-VTjQFKyByBpCXk6k8s/o/xO5m98oJ/raLvDt3XLCehjMgqyePfv0tKjIg2MlPKe/Bbog1RgEix1O1RJy9vL4xw== + dependencies: + "@tiptap/core" "^2.0.0-beta.204" + "@tiptap/extension-blockquote" "^2.0.0-beta.204" + "@tiptap/extension-bold" "^2.0.0-beta.204" + "@tiptap/extension-bullet-list" "^2.0.0-beta.204" + "@tiptap/extension-code" "^2.0.0-beta.204" + "@tiptap/extension-code-block" "^2.0.0-beta.204" + "@tiptap/extension-document" "^2.0.0-beta.204" + "@tiptap/extension-dropcursor" "^2.0.0-beta.204" + "@tiptap/extension-gapcursor" "^2.0.0-beta.204" + "@tiptap/extension-hard-break" "^2.0.0-beta.204" + "@tiptap/extension-heading" "^2.0.0-beta.204" + "@tiptap/extension-history" "^2.0.0-beta.204" + "@tiptap/extension-horizontal-rule" "^2.0.0-beta.204" + "@tiptap/extension-italic" "^2.0.0-beta.204" + "@tiptap/extension-list-item" "^2.0.0-beta.204" + "@tiptap/extension-ordered-list" "^2.0.0-beta.204" + "@tiptap/extension-paragraph" "^2.0.0-beta.204" + "@tiptap/extension-strike" "^2.0.0-beta.204" + "@tiptap/extension-text" "^2.0.0-beta.204" + +"@tiptap/suggestion@2.0.0-beta.204": + version "2.0.0-beta.204" + resolved "https://registry.yarnpkg.com/@tiptap/suggestion/-/suggestion-2.0.0-beta.204.tgz#8239d96da5624a5dd3fb81d8b2dcacb63815f99a" + integrity sha512-cwS9tjYXj6vaozCpsmCxIdbWpdTm4+EJ293RAuoyQLN6n29m3CfFEXpy0A/MwKuzZVZXqZypaDIb97qVkWGJXQ== + dependencies: + prosemirror-model "^1.18.1" + prosemirror-state "^1.4.1" + prosemirror-view "^1.28.2" + +"@types/babel__core@^7.1.14": + version "7.20.5" + resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.20.5.tgz#3df15f27ba85319caa07ba08d0721889bb39c017" + integrity sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA== + dependencies: + "@babel/parser" "^7.20.7" + "@babel/types" "^7.20.7" + "@types/babel__generator" "*" + "@types/babel__template" "*" + "@types/babel__traverse" "*" + +"@types/babel__generator@*": + version "7.6.8" + resolved "https://registry.yarnpkg.com/@types/babel__generator/-/babel__generator-7.6.8.tgz#f836c61f48b1346e7d2b0d93c6dacc5b9535d3ab" + integrity sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw== + dependencies: + "@babel/types" "^7.0.0" + +"@types/babel__template@*": + version "7.4.4" + resolved "https://registry.yarnpkg.com/@types/babel__template/-/babel__template-7.4.4.tgz#5672513701c1b2199bc6dad636a9d7491586766f" + integrity sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A== + dependencies: + "@babel/parser" "^7.1.0" + "@babel/types" "^7.0.0" + +"@types/babel__traverse@*", "@types/babel__traverse@^7.0.6": + version "7.20.5" + resolved "https://registry.yarnpkg.com/@types/babel__traverse/-/babel__traverse-7.20.5.tgz#7b7502be0aa80cc4ef22978846b983edaafcd4dd" + integrity sha512-WXCyOcRtH37HAUkpXhUduaxdm82b4GSlyTqajXviN4EfiuPgNYR109xMCKvpl6zPIpua0DGlMEDCq+g8EdoheQ== + dependencies: + "@babel/types" "^7.20.7" + +"@types/graceful-fs@^4.1.3": + version "4.1.9" + resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.9.tgz#2a06bc0f68a20ab37b3e36aa238be6abdf49e8b4" + integrity sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ== + dependencies: + "@types/node" "*" + +"@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": + version "2.0.6" + resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz#7739c232a1fee9b4d3ce8985f314c0c6d33549d7" + integrity sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w== + +"@types/istanbul-lib-report@*": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/istanbul-lib-report/-/istanbul-lib-report-3.0.3.tgz#53047614ae72e19fc0401d872de3ae2b4ce350bf" + integrity sha512-NQn7AHQnk/RSLOxrBbGyJM/aVQ+pjj5HCgasFxc0K/KhoATfQ/47AyUl15I2yBUpihjmas+a+VJBOqecrFH+uA== + dependencies: + "@types/istanbul-lib-coverage" "*" + +"@types/istanbul-reports@^3.0.0": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/istanbul-reports/-/istanbul-reports-3.0.4.tgz#0f03e3d2f670fbdac586e34b433783070cc16f54" + integrity sha512-pk2B1NWalF9toCRu6gjBzR69syFjP4Od8WRAX+0mmf9lAjCRicLOWc+ZrxZHx/0XRjotgkF9t6iaMJ+aXcOdZQ== + dependencies: + "@types/istanbul-lib-report" "*" + +"@types/jest@29.2.4": + version "29.2.4" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.2.4.tgz#9c155c4b81c9570dbd183eb8604aa0ae80ba5a5b" + integrity sha512-PipFB04k2qTRPePduVLTRiPzQfvMeLwUN3Z21hsAKaB/W9IIzgB2pizCL466ftJlcyZqnHoC9ZHpxLGl3fS86A== + dependencies: + expect "^29.0.0" + pretty-format "^29.0.0" + +"@types/lodash@4.14.178": + version "4.14.178" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.178.tgz#341f6d2247db528d4a13ddbb374bcdc80406f4f8" + integrity sha512-0d5Wd09ItQWH1qFbEyQ7oTQ3GZrMfth5JkbN3EvTKLXcHLRDSXeLnlvlOn0wvxVIwK5o2M8JzP/OWz7T3NRsbw== + +"@types/node@*": + version "20.12.11" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.12.11.tgz#c4ef00d3507000d17690643278a60dc55a9dc9be" + integrity sha512-vDg9PZ/zi+Nqp6boSOT7plNuthRugEKixDv5sFTIpkE89MmNtEArAShI4mxuX2+UrLEe9pxC1vm2cjm9YlWbJw== + dependencies: + undici-types "~5.26.4" + +"@types/phoenix@^1.5.4": + version "1.6.4" + resolved "https://registry.yarnpkg.com/@types/phoenix/-/phoenix-1.6.4.tgz#cceac93a827555473ad38057d1df7d06eef1ed71" + integrity sha512-B34A7uot1Cv0XtaHRYDATltAdKx0BvVKNgYNqE4WjtPUa4VQJM7kxeXcVKaH+KS+kCmZ+6w+QaUdcljiheiBJA== + +"@types/stack-utils@^2.0.0": + version "2.0.3" + resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8" + integrity sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw== + +"@types/string-similarity@4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@types/string-similarity/-/string-similarity-4.0.0.tgz#8cc03d5d1baad2b74530fe6c7d849d5768d391ad" + integrity sha512-dMS4S07fbtY1AILG/RhuwmptmzK1Ql8scmAebOTJ/8iBtK/KI17NwGwKzu1uipjj8Kk+3mfPxum56kKZE93mzQ== + +"@types/ws@^8.5.10": + version "8.5.10" + resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.10.tgz#4acfb517970853fa6574a3a6886791d04a396787" + integrity sha512-vmQSUcfalpIq0R9q7uTo2lXs6eGIpt9wtnLdMv9LVpIjCA/+ufZRozlVoVelIYixx1ugCBKDhn89vnsEGOCx9A== + dependencies: + "@types/node" "*" + +"@types/yargs-parser@*": + version "21.0.3" + resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15" + integrity sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ== + +"@types/yargs@^17.0.8": + version "17.0.32" + resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-17.0.32.tgz#030774723a2f7faafebf645f4e5a48371dca6229" + integrity sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog== + dependencies: + "@types/yargs-parser" "*" + +abort-controller@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" + integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== + dependencies: + event-target-shim "^5.0.0" + +ansi-escapes@^4.2.1: + version "4.3.2" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" + integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ== + dependencies: + type-fest "^0.21.3" + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-styles@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" + integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== + dependencies: + color-convert "^1.9.0" + +ansi-styles@^4.0.0, ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +ansi-styles@^5.0.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-5.2.0.tgz#07449690ad45777d1924ac2abb2fc8895dba836b" + integrity sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA== + +anymatch@^3.0.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" + integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + +argparse@^1.0.7: + version "1.0.10" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" + integrity sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg== + dependencies: + sprintf-js "~1.0.2" + +babel-jest@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-29.7.0.tgz#f4369919225b684c56085998ac63dbd05be020d5" + integrity sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg== + dependencies: + "@jest/transform" "^29.7.0" + "@types/babel__core" "^7.1.14" + babel-plugin-istanbul "^6.1.1" + babel-preset-jest "^29.6.3" + chalk "^4.0.0" + graceful-fs "^4.2.9" + slash "^3.0.0" + +babel-plugin-istanbul@^6.1.1: + version "6.1.1" + resolved "https://registry.yarnpkg.com/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz#fa88ec59232fd9b4e36dbbc540a8ec9a9b47da73" + integrity sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@istanbuljs/load-nyc-config" "^1.0.0" + "@istanbuljs/schema" "^0.1.2" + istanbul-lib-instrument "^5.0.4" + test-exclude "^6.0.0" + +babel-plugin-jest-hoist@^29.6.3: + version "29.6.3" + resolved "https://registry.yarnpkg.com/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz#aadbe943464182a8922c3c927c3067ff40d24626" + integrity sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg== + dependencies: + "@babel/template" "^7.3.3" + "@babel/types" "^7.3.3" + "@types/babel__core" "^7.1.14" + "@types/babel__traverse" "^7.0.6" + +babel-preset-current-node-syntax@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz#b4399239b89b2a011f9ddbe3e4f401fc40cff73b" + integrity sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ== + dependencies: + "@babel/plugin-syntax-async-generators" "^7.8.4" + "@babel/plugin-syntax-bigint" "^7.8.3" + "@babel/plugin-syntax-class-properties" "^7.8.3" + "@babel/plugin-syntax-import-meta" "^7.8.3" + "@babel/plugin-syntax-json-strings" "^7.8.3" + "@babel/plugin-syntax-logical-assignment-operators" "^7.8.3" + "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" + "@babel/plugin-syntax-numeric-separator" "^7.8.3" + "@babel/plugin-syntax-object-rest-spread" "^7.8.3" + "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" + "@babel/plugin-syntax-optional-chaining" "^7.8.3" + "@babel/plugin-syntax-top-level-await" "^7.8.3" + +babel-preset-jest@^29.6.3: + version "29.6.3" + resolved "https://registry.yarnpkg.com/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz#fa05fa510e7d493896d7b0dd2033601c840f171c" + integrity sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA== + dependencies: + babel-plugin-jest-hoist "^29.6.3" + babel-preset-current-node-syntax "^1.0.0" + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +boolbase@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" + integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +braces@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" + integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== + dependencies: + fill-range "^7.0.1" + +browserslist@^4.22.2: + version "4.23.0" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.23.0.tgz#8f3acc2bbe73af7213399430890f86c63a5674ab" + integrity sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ== + dependencies: + caniuse-lite "^1.0.30001587" + electron-to-chromium "^1.4.668" + node-releases "^2.0.14" + update-browserslist-db "^1.0.13" + +bs-logger@0.x: + version "0.2.6" + resolved "https://registry.yarnpkg.com/bs-logger/-/bs-logger-0.2.6.tgz#eb7d365307a72cf974cc6cda76b68354ad336bd8" + integrity sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog== + dependencies: + fast-json-stable-stringify "2.x" + +bser@2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/bser/-/bser-2.1.1.tgz#e6787da20ece9d07998533cfd9de6f5c38f4bc05" + integrity sha512-gQxTNE/GAfIIrmHLUE3oJyp5FO6HRBfhjnw4/wMmA63ZGDJnWBmgY/lyQBpnDUkGmAhbSe39tx2d/iTOAfglwQ== + dependencies: + node-int64 "^0.4.0" + +buffer-from@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" + integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== + +callsites@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" + integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== + +camelcase@^5.3.1: + version "5.3.1" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" + integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== + +camelcase@^6.2.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" + integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== + +caniuse-lite@^1.0.30001587: + version "1.0.30001617" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001617.tgz#809bc25f3f5027ceb33142a7d6c40759d7a901eb" + integrity sha512-mLyjzNI9I+Pix8zwcrpxEbGlfqOkF9kM3ptzmKNw5tizSyYwMe+nGLTqMK9cO+0E+Bh6TsBxNAaHWEM8xwSsmA== + +chalk@^2.4.2: + version "2.4.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" + integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== + dependencies: + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" + +chalk@^4.0.0: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +char-regex@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf" + integrity sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw== + +cheerio-select@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/cheerio-select/-/cheerio-select-2.1.0.tgz#4d8673286b8126ca2a8e42740d5e3c4884ae21b4" + integrity sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g== + dependencies: + boolbase "^1.0.0" + css-select "^5.1.0" + css-what "^6.1.0" + domelementtype "^2.3.0" + domhandler "^5.0.3" + domutils "^3.0.1" + +cheerio@1.0.0-rc.11: + version "1.0.0-rc.11" + resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.11.tgz#1be84be1a126958366bcc57a11648cd9b30a60c2" + integrity sha512-bQwNaDIBKID5ts/DsdhxrjqFXYfLw4ste+wMKqWA8DyKcS4qwsPP4Bk8ZNaTJjvpiX/qW3BT4sU7d6Bh5i+dag== + dependencies: + cheerio-select "^2.1.0" + dom-serializer "^2.0.0" + domhandler "^5.0.3" + domutils "^3.0.1" + htmlparser2 "^8.0.1" + parse5 "^7.0.0" + parse5-htmlparser2-tree-adapter "^7.0.0" + tslib "^2.4.0" + +ci-info@^3.2.0: + version "3.9.0" + resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" + integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== + +cjs-module-lexer@^1.0.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.3.1.tgz#c485341ae8fd999ca4ee5af2d7a1c9ae01e0099c" + integrity sha512-a3KdPAANPbNE4ZUv9h6LckSl9zLsYOP4MBmhIPkRaeyybt+r4UghLvq+xw/YwUcC1gqylCkL4rdVs3Lwupjm4Q== + +cliui@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-8.0.1.tgz#0c04b075db02cbfe60dc8e6cf2f5486b1a3608aa" + integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.1" + wrap-ansi "^7.0.0" + +co@^4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" + integrity sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ== + +collect-v8-coverage@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz#c0b29bcd33bcd0779a1344c2136051e6afd3d9e9" + integrity sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q== + +color-convert@^1.9.0: + version "1.9.3" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" + integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== + dependencies: + color-name "1.1.3" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" + integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== + +convert-source-map@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-2.0.0.tgz#4b560f649fc4e918dd0ab75cf4961e8bc882d82a" + integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== + +create-jest@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/create-jest/-/create-jest-29.7.0.tgz#a355c5b3cb1e1af02ba177fe7afd7feee49a5320" + integrity sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q== + dependencies: + "@jest/types" "^29.6.3" + chalk "^4.0.0" + exit "^0.1.2" + graceful-fs "^4.2.9" + jest-config "^29.7.0" + jest-util "^29.7.0" + prompts "^2.0.1" + +cross-fetch@3.1.5: + version "3.1.5" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.5.tgz#e1389f44d9e7ba767907f7af8454787952ab534f" + integrity sha512-lvb1SBsI0Z7GDwmuid+mU3kWVBwTVUbe7S0H52yaaAdQOXq2YktTCZdlAcNKFzE6QtRz0snpw9bNiPeOIkkQvw== + dependencies: + node-fetch "2.6.7" + +cross-spawn@^7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" + integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +css-select@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/css-select/-/css-select-5.1.0.tgz#b8ebd6554c3637ccc76688804ad3f6a6fdaea8a6" + integrity sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg== + dependencies: + boolbase "^1.0.0" + css-what "^6.1.0" + domhandler "^5.0.2" + domutils "^3.0.1" + nth-check "^2.0.1" + +css-what@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4" + integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== + +dayjs@1.11.4: + version "1.11.4" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.4.tgz#3b3c10ca378140d8917e06ebc13a4922af4f433e" + integrity sha512-Zj/lPM5hOvQ1Bf7uAvewDaUcsJoI6JmNqmHhHl3nyumwe0XHwt8sWdOVAPACJzCebL8gQCi+K49w7iKWnGwX9g== + +debug@^4.1.0, debug@^4.1.1, debug@^4.3.1: + version "4.3.4" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" + integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== + dependencies: + ms "2.1.2" + +dedent@^1.0.0: + version "1.5.3" + resolved "https://registry.yarnpkg.com/dedent/-/dedent-1.5.3.tgz#99aee19eb9bae55a67327717b6e848d0bf777e5a" + integrity sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ== + +deepmerge@^4.2.2: + version "4.3.1" + resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.3.1.tgz#44b5f2147cd3b00d4b56137685966f26fd25dd4a" + integrity sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A== + +detect-newline@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-3.1.0.tgz#576f5dfc63ae1a192ff192d8ad3af6308991b651" + integrity sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA== + +diff-sequences@^29.6.3: + version "29.6.3" + resolved "https://registry.yarnpkg.com/diff-sequences/-/diff-sequences-29.6.3.tgz#4deaf894d11407c51efc8418012f9e70b84ea921" + integrity sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q== + +dom-serializer@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-2.0.0.tgz#e41b802e1eedf9f6cae183ce5e622d789d7d8e53" + integrity sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.2" + entities "^4.2.0" + +domelementtype@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/domelementtype/-/domelementtype-2.3.0.tgz#5c45e8e869952626331d7aab326d01daf65d589d" + integrity sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw== + +domhandler@^5.0.2, domhandler@^5.0.3: + version "5.0.3" + resolved "https://registry.yarnpkg.com/domhandler/-/domhandler-5.0.3.tgz#cc385f7f751f1d1fc650c21374804254538c7d31" + integrity sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w== + dependencies: + domelementtype "^2.3.0" + +domutils@^3.0.1: + version "3.1.0" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.1.0.tgz#c47f551278d3dc4b0b1ab8cbb42d751a6f0d824e" + integrity sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA== + dependencies: + dom-serializer "^2.0.0" + domelementtype "^2.3.0" + domhandler "^5.0.3" + +electron-to-chromium@^1.4.668: + version "1.4.763" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.763.tgz#64f2041ed496fd6fc710b9be806fe91da9334f91" + integrity sha512-k4J8NrtJ9QrvHLRo8Q18OncqBCB7tIUyqxRcJnlonQ0ioHKYB988GcDFF3ZePmnb8eHEopDs/wPHR/iGAFgoUQ== + +emittery@^0.13.1: + version "0.13.1" + resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.13.1.tgz#c04b8c3457490e0847ae51fced3af52d338e3dad" + integrity sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ== + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +entities@^4.2.0, entities@^4.4.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" + integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + +error-ex@^1.3.1: + version "1.3.2" + resolved "https://registry.yarnpkg.com/error-ex/-/error-ex-1.3.2.tgz#b4ac40648107fdcdcfae242f428bea8a14d4f1bf" + integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== + dependencies: + is-arrayish "^0.2.1" + +escalade@^3.1.1, escalade@^3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.2.tgz#54076e9ab29ea5bf3d8f1ed62acffbb88272df27" + integrity sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA== + +escape-string-regexp@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" + integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== + +escape-string-regexp@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz#a30304e99daa32e23b2fd20f51babd07cffca344" + integrity sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w== + +esprima@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" + integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== + +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + +execa@^5.0.0: + version "5.1.1" + resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" + integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg== + dependencies: + cross-spawn "^7.0.3" + get-stream "^6.0.0" + human-signals "^2.1.0" + is-stream "^2.0.0" + merge-stream "^2.0.0" + npm-run-path "^4.0.1" + onetime "^5.1.2" + signal-exit "^3.0.3" + strip-final-newline "^2.0.0" + +exit@^0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c" + integrity sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ== + +expect@^29.0.0, expect@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/expect/-/expect-29.7.0.tgz#578874590dcb3214514084c08115d8aee61e11bc" + integrity sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw== + dependencies: + "@jest/expect-utils" "^29.7.0" + jest-get-type "^29.6.3" + jest-matcher-utils "^29.7.0" + jest-message-util "^29.7.0" + jest-util "^29.7.0" + +fast-json-stable-stringify@2.x, fast-json-stable-stringify@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +fb-watchman@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.2.tgz#e9524ee6b5c77e9e5001af0f85f3adbb8623255c" + integrity sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA== + dependencies: + bser "2.1.1" + +fill-range@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" + integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== + dependencies: + to-regex-range "^5.0.1" + +find-up@^4.0.0, find-up@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" + integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== + dependencies: + locate-path "^5.0.0" + path-exists "^4.0.0" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== + +fsevents@^2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + +gensync@^1.0.0-beta.2: + version "1.0.0-beta.2" + resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" + integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== + +get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +get-package-type@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/get-package-type/-/get-package-type-0.1.0.tgz#8de2d803cff44df3bc6c456e6668b36c3926e11a" + integrity sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q== + +get-stream@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" + integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== + +glob@^7.1.3, glob@^7.1.4: + version "7.2.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.1.1" + once "^1.3.0" + path-is-absolute "^1.0.0" + +globals@^11.1.0: + version "11.12.0" + resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" + integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== + +graceful-fs@^4.2.9: + version "4.2.11" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== + +has-flag@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" + integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +hasown@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.2.tgz#003eaf91be7adc372e84ec59dc37252cedb80003" + integrity sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ== + dependencies: + function-bind "^1.1.2" + +html-escaper@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/html-escaper/-/html-escaper-2.0.2.tgz#dfd60027da36a36dfcbe236262c00a5822681453" + integrity sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg== + +htmlparser2@^8.0.1: + version "8.0.2" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-8.0.2.tgz#f002151705b383e62433b5cf466f5b716edaec21" + integrity sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.3" + domutils "^3.0.1" + entities "^4.4.0" + +human-signals@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" + integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== + +import-local@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/import-local/-/import-local-3.1.0.tgz#b4479df8a5fd44f6cdce24070675676063c95cb4" + integrity sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg== + dependencies: + pkg-dir "^4.2.0" + resolve-cwd "^3.0.0" + +imurmurhash@^0.1.4: + version "0.1.4" + resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea" + integrity sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA== + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +is-arrayish@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.2.1.tgz#77c99840527aa8ecb1a8ba697b80645a7a926a9d" + integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== + +is-core-module@^2.13.0: + version "2.13.1" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.13.1.tgz#ad0d7532c6fea9da1ebdc82742d74525c6273384" + integrity sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw== + dependencies: + hasown "^2.0.0" + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-generator-fn@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-generator-fn/-/is-generator-fn-2.1.0.tgz#7d140adc389aaf3011a8f2a2a4cfa6faadffb118" + integrity sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ== + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +is-stream@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" + integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.0: + version "3.2.2" + resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz#2d166c4b0644d43a39f04bf6c2edd1e585f31756" + integrity sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg== + +istanbul-lib-instrument@^5.0.4: + version "5.2.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz#d10c8885c2125574e1c231cacadf955675e1ce3d" + integrity sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg== + dependencies: + "@babel/core" "^7.12.3" + "@babel/parser" "^7.14.7" + "@istanbuljs/schema" "^0.1.2" + istanbul-lib-coverage "^3.2.0" + semver "^6.3.0" + +istanbul-lib-instrument@^6.0.0: + version "6.0.2" + resolved "https://registry.yarnpkg.com/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.2.tgz#91655936cf7380e4e473383081e38478b69993b1" + integrity sha512-1WUsZ9R1lA0HtBSohTkm39WTPlNKSJ5iFk7UwqXkBLoHQT+hfqPsfsTDVuZdKGaBwn7din9bS7SsnoAr943hvw== + dependencies: + "@babel/core" "^7.23.9" + "@babel/parser" "^7.23.9" + "@istanbuljs/schema" "^0.1.3" + istanbul-lib-coverage "^3.2.0" + semver "^7.5.4" + +istanbul-lib-report@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz#908305bac9a5bd175ac6a74489eafd0fc2445a7d" + integrity sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw== + dependencies: + istanbul-lib-coverage "^3.0.0" + make-dir "^4.0.0" + supports-color "^7.1.0" + +istanbul-lib-source-maps@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz#895f3a709fcfba34c6de5a42939022f3e4358551" + integrity sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw== + dependencies: + debug "^4.1.1" + istanbul-lib-coverage "^3.0.0" + source-map "^0.6.1" + +istanbul-reports@^3.1.3: + version "3.1.7" + resolved "https://registry.yarnpkg.com/istanbul-reports/-/istanbul-reports-3.1.7.tgz#daed12b9e1dca518e15c056e1e537e741280fa0b" + integrity sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g== + dependencies: + html-escaper "^2.0.0" + istanbul-lib-report "^3.0.0" + +jest-changed-files@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-29.7.0.tgz#1c06d07e77c78e1585d020424dedc10d6e17ac3a" + integrity sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w== + dependencies: + execa "^5.0.0" + jest-util "^29.7.0" + p-limit "^3.1.0" + +jest-circus@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-circus/-/jest-circus-29.7.0.tgz#b6817a45fcc835d8b16d5962d0c026473ee3668a" + integrity sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw== + dependencies: + "@jest/environment" "^29.7.0" + "@jest/expect" "^29.7.0" + "@jest/test-result" "^29.7.0" + "@jest/types" "^29.6.3" + "@types/node" "*" + chalk "^4.0.0" + co "^4.6.0" + dedent "^1.0.0" + is-generator-fn "^2.0.0" + jest-each "^29.7.0" + jest-matcher-utils "^29.7.0" + jest-message-util "^29.7.0" + jest-runtime "^29.7.0" + jest-snapshot "^29.7.0" + jest-util "^29.7.0" + p-limit "^3.1.0" + pretty-format "^29.7.0" + pure-rand "^6.0.0" + slash "^3.0.0" + stack-utils "^2.0.3" + +jest-cli@^29.3.1: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-cli/-/jest-cli-29.7.0.tgz#5592c940798e0cae677eec169264f2d839a37995" + integrity sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg== + dependencies: + "@jest/core" "^29.7.0" + "@jest/test-result" "^29.7.0" + "@jest/types" "^29.6.3" + chalk "^4.0.0" + create-jest "^29.7.0" + exit "^0.1.2" + import-local "^3.0.2" + jest-config "^29.7.0" + jest-util "^29.7.0" + jest-validate "^29.7.0" + yargs "^17.3.1" + +jest-config@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-config/-/jest-config-29.7.0.tgz#bcbda8806dbcc01b1e316a46bb74085a84b0245f" + integrity sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ== + dependencies: + "@babel/core" "^7.11.6" + "@jest/test-sequencer" "^29.7.0" + "@jest/types" "^29.6.3" + babel-jest "^29.7.0" + chalk "^4.0.0" + ci-info "^3.2.0" + deepmerge "^4.2.2" + glob "^7.1.3" + graceful-fs "^4.2.9" + jest-circus "^29.7.0" + jest-environment-node "^29.7.0" + jest-get-type "^29.6.3" + jest-regex-util "^29.6.3" + jest-resolve "^29.7.0" + jest-runner "^29.7.0" + jest-util "^29.7.0" + jest-validate "^29.7.0" + micromatch "^4.0.4" + parse-json "^5.2.0" + pretty-format "^29.7.0" + slash "^3.0.0" + strip-json-comments "^3.1.1" + +jest-diff@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-diff/-/jest-diff-29.7.0.tgz#017934a66ebb7ecf6f205e84699be10afd70458a" + integrity sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw== + dependencies: + chalk "^4.0.0" + diff-sequences "^29.6.3" + jest-get-type "^29.6.3" + pretty-format "^29.7.0" + +jest-docblock@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-docblock/-/jest-docblock-29.7.0.tgz#8fddb6adc3cdc955c93e2a87f61cfd350d5d119a" + integrity sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g== + dependencies: + detect-newline "^3.0.0" + +jest-each@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-each/-/jest-each-29.7.0.tgz#162a9b3f2328bdd991beaabffbb74745e56577d1" + integrity sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ== + dependencies: + "@jest/types" "^29.6.3" + chalk "^4.0.0" + jest-get-type "^29.6.3" + jest-util "^29.7.0" + pretty-format "^29.7.0" + +jest-environment-node@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-environment-node/-/jest-environment-node-29.7.0.tgz#0b93e111dda8ec120bc8300e6d1fb9576e164376" + integrity sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw== + dependencies: + "@jest/environment" "^29.7.0" + "@jest/fake-timers" "^29.7.0" + "@jest/types" "^29.6.3" + "@types/node" "*" + jest-mock "^29.7.0" + jest-util "^29.7.0" + +jest-get-type@^29.6.3: + version "29.6.3" + resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-29.6.3.tgz#36f499fdcea197c1045a127319c0481723908fd1" + integrity sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw== + +jest-haste-map@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-haste-map/-/jest-haste-map-29.7.0.tgz#3c2396524482f5a0506376e6c858c3bbcc17b104" + integrity sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA== + dependencies: + "@jest/types" "^29.6.3" + "@types/graceful-fs" "^4.1.3" + "@types/node" "*" + anymatch "^3.0.3" + fb-watchman "^2.0.0" + graceful-fs "^4.2.9" + jest-regex-util "^29.6.3" + jest-util "^29.7.0" + jest-worker "^29.7.0" + micromatch "^4.0.4" + walker "^1.0.8" + optionalDependencies: + fsevents "^2.3.2" + +jest-leak-detector@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz#5b7ec0dadfdfec0ca383dc9aa016d36b5ea4c728" + integrity sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw== + dependencies: + jest-get-type "^29.6.3" + pretty-format "^29.7.0" + +jest-matcher-utils@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz#ae8fec79ff249fd592ce80e3ee474e83a6c44f12" + integrity sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g== + dependencies: + chalk "^4.0.0" + jest-diff "^29.7.0" + jest-get-type "^29.6.3" + pretty-format "^29.7.0" + +jest-message-util@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-message-util/-/jest-message-util-29.7.0.tgz#8bc392e204e95dfe7564abbe72a404e28e51f7f3" + integrity sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w== + dependencies: + "@babel/code-frame" "^7.12.13" + "@jest/types" "^29.6.3" + "@types/stack-utils" "^2.0.0" + chalk "^4.0.0" + graceful-fs "^4.2.9" + micromatch "^4.0.4" + pretty-format "^29.7.0" + slash "^3.0.0" + stack-utils "^2.0.3" + +jest-mock@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-mock/-/jest-mock-29.7.0.tgz#4e836cf60e99c6fcfabe9f99d017f3fdd50a6347" + integrity sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw== + dependencies: + "@jest/types" "^29.6.3" + "@types/node" "*" + jest-util "^29.7.0" + +jest-pnp-resolver@^1.2.2: + version "1.2.3" + resolved "https://registry.yarnpkg.com/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz#930b1546164d4ad5937d5540e711d4d38d4cad2e" + integrity sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w== + +jest-regex-util@^29.6.3: + version "29.6.3" + resolved "https://registry.yarnpkg.com/jest-regex-util/-/jest-regex-util-29.6.3.tgz#4a556d9c776af68e1c5f48194f4d0327d24e8a52" + integrity sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg== + +jest-resolve-dependencies@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz#1b04f2c095f37fc776ff40803dc92921b1e88428" + integrity sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA== + dependencies: + jest-regex-util "^29.6.3" + jest-snapshot "^29.7.0" + +jest-resolve@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-resolve/-/jest-resolve-29.7.0.tgz#64d6a8992dd26f635ab0c01e5eef4399c6bcbc30" + integrity sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA== + dependencies: + chalk "^4.0.0" + graceful-fs "^4.2.9" + jest-haste-map "^29.7.0" + jest-pnp-resolver "^1.2.2" + jest-util "^29.7.0" + jest-validate "^29.7.0" + resolve "^1.20.0" + resolve.exports "^2.0.0" + slash "^3.0.0" + +jest-runner@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-runner/-/jest-runner-29.7.0.tgz#809af072d408a53dcfd2e849a4c976d3132f718e" + integrity sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ== + dependencies: + "@jest/console" "^29.7.0" + "@jest/environment" "^29.7.0" + "@jest/test-result" "^29.7.0" + "@jest/transform" "^29.7.0" + "@jest/types" "^29.6.3" + "@types/node" "*" + chalk "^4.0.0" + emittery "^0.13.1" + graceful-fs "^4.2.9" + jest-docblock "^29.7.0" + jest-environment-node "^29.7.0" + jest-haste-map "^29.7.0" + jest-leak-detector "^29.7.0" + jest-message-util "^29.7.0" + jest-resolve "^29.7.0" + jest-runtime "^29.7.0" + jest-util "^29.7.0" + jest-watcher "^29.7.0" + jest-worker "^29.7.0" + p-limit "^3.1.0" + source-map-support "0.5.13" + +jest-runtime@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-runtime/-/jest-runtime-29.7.0.tgz#efecb3141cf7d3767a3a0cc8f7c9990587d3d817" + integrity sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ== + dependencies: + "@jest/environment" "^29.7.0" + "@jest/fake-timers" "^29.7.0" + "@jest/globals" "^29.7.0" + "@jest/source-map" "^29.6.3" + "@jest/test-result" "^29.7.0" + "@jest/transform" "^29.7.0" + "@jest/types" "^29.6.3" + "@types/node" "*" + chalk "^4.0.0" + cjs-module-lexer "^1.0.0" + collect-v8-coverage "^1.0.0" + glob "^7.1.3" + graceful-fs "^4.2.9" + jest-haste-map "^29.7.0" + jest-message-util "^29.7.0" + jest-mock "^29.7.0" + jest-regex-util "^29.6.3" + jest-resolve "^29.7.0" + jest-snapshot "^29.7.0" + jest-util "^29.7.0" + slash "^3.0.0" + strip-bom "^4.0.0" + +jest-snapshot@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-snapshot/-/jest-snapshot-29.7.0.tgz#c2c574c3f51865da1bb329036778a69bf88a6be5" + integrity sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw== + dependencies: + "@babel/core" "^7.11.6" + "@babel/generator" "^7.7.2" + "@babel/plugin-syntax-jsx" "^7.7.2" + "@babel/plugin-syntax-typescript" "^7.7.2" + "@babel/types" "^7.3.3" + "@jest/expect-utils" "^29.7.0" + "@jest/transform" "^29.7.0" + "@jest/types" "^29.6.3" + babel-preset-current-node-syntax "^1.0.0" + chalk "^4.0.0" + expect "^29.7.0" + graceful-fs "^4.2.9" + jest-diff "^29.7.0" + jest-get-type "^29.6.3" + jest-matcher-utils "^29.7.0" + jest-message-util "^29.7.0" + jest-util "^29.7.0" + natural-compare "^1.4.0" + pretty-format "^29.7.0" + semver "^7.5.3" + +jest-util@^29.0.0, jest-util@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-util/-/jest-util-29.7.0.tgz#23c2b62bfb22be82b44de98055802ff3710fc0bc" + integrity sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA== + dependencies: + "@jest/types" "^29.6.3" + "@types/node" "*" + chalk "^4.0.0" + ci-info "^3.2.0" + graceful-fs "^4.2.9" + picomatch "^2.2.3" + +jest-validate@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-validate/-/jest-validate-29.7.0.tgz#7bf705511c64da591d46b15fce41400d52147d9c" + integrity sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw== + dependencies: + "@jest/types" "^29.6.3" + camelcase "^6.2.0" + chalk "^4.0.0" + jest-get-type "^29.6.3" + leven "^3.1.0" + pretty-format "^29.7.0" + +jest-watcher@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-watcher/-/jest-watcher-29.7.0.tgz#7810d30d619c3a62093223ce6bb359ca1b28a2f2" + integrity sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g== + dependencies: + "@jest/test-result" "^29.7.0" + "@jest/types" "^29.6.3" + "@types/node" "*" + ansi-escapes "^4.2.1" + chalk "^4.0.0" + emittery "^0.13.1" + jest-util "^29.7.0" + string-length "^4.0.1" + +jest-worker@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-29.7.0.tgz#acad073acbbaeb7262bd5389e1bcf43e10058d4a" + integrity sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw== + dependencies: + "@types/node" "*" + jest-util "^29.7.0" + merge-stream "^2.0.0" + supports-color "^8.0.0" + +jest@29.3.1: + version "29.3.1" + resolved "https://registry.yarnpkg.com/jest/-/jest-29.3.1.tgz#c130c0d551ae6b5459b8963747fed392ddbde122" + integrity sha512-6iWfL5DTT0Np6UYs/y5Niu7WIfNv/wRTtN5RSXt2DIEft3dx3zPuw/3WJQBCJfmEzvDiEKwoqMbGD9n49+qLSA== + dependencies: + "@jest/core" "^29.3.1" + "@jest/types" "^29.3.1" + import-local "^3.0.2" + jest-cli "^29.3.1" + +js-tokens@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" + integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== + +js-yaml@^3.13.1: + version "3.14.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.1.tgz#dae812fdb3825fa306609a8717383c50c36a0537" + integrity sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g== + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + +jsesc@^2.5.1: + version "2.5.2" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" + integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA== + +json-parse-even-better-errors@^2.3.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" + integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== + +json5@^2.2.1, json5@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" + integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== + +kleur@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" + integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== + +leven@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/leven/-/leven-3.1.0.tgz#77891de834064cccba82ae7842bb6b14a13ed7f2" + integrity sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A== + +lines-and-columns@^1.1.6: + version "1.2.4" + resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" + integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== + +link-preview-js@3.0.4: + version "3.0.4" + resolved "https://registry.yarnpkg.com/link-preview-js/-/link-preview-js-3.0.4.tgz#1e053f80ee20ef50d03e6742dc30dc8487112653" + integrity sha512-xsuxMigAZd4xmj6BIwMNuQjjpJdh0DWeIo1NXQgaoWSi9Z/dzz/Kxy6vzzsUonFlMTPJ1i0EC8aeOg/xrOMidg== + dependencies: + abort-controller "^3.0.0" + cheerio "1.0.0-rc.11" + cross-fetch "3.1.5" + url "0.11.0" + +linkifyjs@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-3.0.5.tgz#99e51a3a0c0e232fcb63ebb89eea3ff923378f34" + integrity sha512-1Y9XQH65eQKA9p2xtk+zxvnTeQBG7rdAXSkUG97DmuI/Xhji9uaUzaWxRj6rf9YC0v8KKHkxav7tnLX82Sz5Fg== + +locate-path@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" + integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== + dependencies: + p-locate "^4.1.0" + +lodash.memoize@4.x: + version "4.1.2" + resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" + integrity sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag== + +lodash@4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + +lru-cache@^5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" + integrity sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w== + dependencies: + yallist "^3.0.2" + +make-dir@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-4.0.0.tgz#c3c2307a771277cd9638305f915c29ae741b614e" + integrity sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw== + dependencies: + semver "^7.5.3" + +make-error@1.x: + version "1.3.6" + resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" + integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== + +makeerror@1.0.12: + version "1.0.12" + resolved "https://registry.yarnpkg.com/makeerror/-/makeerror-1.0.12.tgz#3e5dd2079a82e812e983cc6610c4a2cb0eaa801a" + integrity sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg== + dependencies: + tmpl "1.0.5" + +merge-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" + integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== + +micromatch@^4.0.4: + version "4.0.5" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6" + integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA== + dependencies: + braces "^3.0.2" + picomatch "^2.3.1" + +mimic-fn@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" + integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== + +minimatch@^3.0.4, minimatch@^3.1.1: + version "3.1.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +ms@2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" + integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== + +natural-compare@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" + integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== + +node-fetch@2.6.7: + version "2.6.7" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" + integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== + dependencies: + whatwg-url "^5.0.0" + +node-int64@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/node-int64/-/node-int64-0.4.0.tgz#87a9065cdb355d3182d8f94ce11188b825c68a3b" + integrity sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw== + +node-releases@^2.0.14: + version "2.0.14" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.14.tgz#2ffb053bceb8b2be8495ece1ab6ce600c4461b0b" + integrity sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw== + +normalize-path@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + +npm-run-path@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" + integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== + dependencies: + path-key "^3.0.0" + +nth-check@^2.0.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d" + integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w== + dependencies: + boolbase "^1.0.0" + +once@^1.3.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + dependencies: + wrappy "1" + +onetime@^5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" + integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== + dependencies: + mimic-fn "^2.1.0" + +orderedmap@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/orderedmap/-/orderedmap-2.1.1.tgz#61481269c44031c449915497bf5a4ad273c512d2" + integrity sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g== + +p-limit@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" + integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== + dependencies: + p-try "^2.0.0" + +p-limit@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + +p-locate@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" + integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== + dependencies: + p-limit "^2.2.0" + +p-try@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" + integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== + +parse-json@^5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/parse-json/-/parse-json-5.2.0.tgz#c76fc66dee54231c962b22bcc8a72cf2f99753cd" + integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== + dependencies: + "@babel/code-frame" "^7.0.0" + error-ex "^1.3.1" + json-parse-even-better-errors "^2.3.0" + lines-and-columns "^1.1.6" + +parse5-htmlparser2-tree-adapter@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz#23c2cc233bcf09bb7beba8b8a69d46b08c62c2f1" + integrity sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g== + dependencies: + domhandler "^5.0.2" + parse5 "^7.0.0" + +parse5@^7.0.0: + version "7.1.2" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.1.2.tgz#0736bebbfd77793823240a23b7fc5e010b7f8e32" + integrity sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw== + dependencies: + entities "^4.4.0" + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== + +path-key@^3.0.0, path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-parse@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== + +picocolors@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" + integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== + +picomatch@^2.0.4, picomatch@^2.2.3, picomatch@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +pirates@^4.0.4: + version "4.0.6" + resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.6.tgz#3018ae32ecfcff6c29ba2267cbf21166ac1f36b9" + integrity sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg== + +pkg-dir@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" + integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== + dependencies: + find-up "^4.0.0" + +pretty-format@^29.0.0, pretty-format@^29.7.0: + version "29.7.0" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.7.0.tgz#ca42c758310f365bfa71a0bda0a807160b776812" + integrity sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ== + dependencies: + "@jest/schemas" "^29.6.3" + ansi-styles "^5.0.0" + react-is "^18.0.0" + +prompts@^2.0.1: + version "2.4.2" + resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069" + integrity sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q== + dependencies: + kleur "^3.0.3" + sisteransi "^1.0.5" + +prosemirror-commands@^1.3.1: + version "1.5.2" + resolved "https://registry.yarnpkg.com/prosemirror-commands/-/prosemirror-commands-1.5.2.tgz#e94aeea52286f658cd984270de9b4c3fff580852" + integrity sha512-hgLcPaakxH8tu6YvVAaILV2tXYsW3rAdDR8WNkeKGcgeMVQg3/TMhPdVoh7iAmfgVjZGtcOSjKiQaoeKjzd2mQ== + dependencies: + prosemirror-model "^1.0.0" + prosemirror-state "^1.0.0" + prosemirror-transform "^1.0.0" + +prosemirror-keymap@^1.2.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/prosemirror-keymap/-/prosemirror-keymap-1.2.2.tgz#14a54763a29c7b2704f561088ccf3384d14eb77e" + integrity sha512-EAlXoksqC6Vbocqc0GtzCruZEzYgrn+iiGnNjsJsH4mrnIGex4qbLdWWNza3AW5W36ZRrlBID0eM6bdKH4OStQ== + dependencies: + prosemirror-state "^1.0.0" + w3c-keyname "^2.2.0" + +prosemirror-model@^1.0.0, prosemirror-model@^1.18.1, prosemirror-model@^1.20.0, prosemirror-model@^1.21.0: + version "1.21.0" + resolved "https://registry.yarnpkg.com/prosemirror-model/-/prosemirror-model-1.21.0.tgz#2d69ed04b4e7c441c3eb87c1c964fab4f9b217df" + integrity sha512-zLpS1mVCZLA7VTp82P+BfMiYVPcX1/z0Mf3gsjKZtzMWubwn2pN7CceMV0DycjlgE5JeXPR7UF4hJPbBV98oWA== + dependencies: + orderedmap "^2.0.0" + +prosemirror-schema-list@^1.2.2: + version "1.3.0" + resolved "https://registry.yarnpkg.com/prosemirror-schema-list/-/prosemirror-schema-list-1.3.0.tgz#05374702cf35a3ba5e7ec31079e355a488d52519" + integrity sha512-Hz/7gM4skaaYfRPNgr421CU4GSwotmEwBVvJh5ltGiffUJwm7C8GfN/Bc6DR1EKEp5pDKhODmdXXyi9uIsZl5A== + dependencies: + prosemirror-model "^1.0.0" + prosemirror-state "^1.0.0" + prosemirror-transform "^1.7.3" + +prosemirror-state@^1.0.0, prosemirror-state@^1.4.1: + version "1.4.3" + resolved "https://registry.yarnpkg.com/prosemirror-state/-/prosemirror-state-1.4.3.tgz#94aecf3ffd54ec37e87aa7179d13508da181a080" + integrity sha512-goFKORVbvPuAQaXhpbemJFRKJ2aixr+AZMGiquiqKxaucC6hlpHNZHWgz5R7dS4roHiwq9vDctE//CZ++o0W1Q== + dependencies: + prosemirror-model "^1.0.0" + prosemirror-transform "^1.0.0" + prosemirror-view "^1.27.0" + +prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0, prosemirror-transform@^1.7.0, prosemirror-transform@^1.7.3: + version "1.9.0" + resolved "https://registry.yarnpkg.com/prosemirror-transform/-/prosemirror-transform-1.9.0.tgz#81fd1fbd887929a95369e6dd3d240c23c19313f8" + integrity sha512-5UXkr1LIRx3jmpXXNKDhv8OyAOeLTGuXNwdVfg8x27uASna/wQkr9p6fD3eupGOi4PLJfbezxTyi/7fSJypXHg== + dependencies: + prosemirror-model "^1.21.0" + +prosemirror-view@^1.27.0, prosemirror-view@^1.28.2: + version "1.33.6" + resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.33.6.tgz#85804eb922411af8e300a07f4f376722b15900b9" + integrity sha512-zRLUNgLIQfd8IfGprsXxWTjdA8xEAFJe8cDNrOptj6Mop9sj+BMeVbJvceyAYCm5G2dOdT2prctH7K9dfnpIMw== + dependencies: + prosemirror-model "^1.20.0" + prosemirror-state "^1.0.0" + prosemirror-transform "^1.1.0" + +punycode@1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" + integrity sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw== + +pure-rand@^6.0.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/pure-rand/-/pure-rand-6.1.0.tgz#d173cf23258231976ccbdb05247c9787957604f2" + integrity sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA== + +querystring@0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/querystring/-/querystring-0.2.0.tgz#b209849203bb25df820da756e747005878521620" + integrity sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g== + +react-is@^18.0.0: + version "18.3.1" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" + integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== + +resolve-cwd@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz#0f0075f1bb2544766cf73ba6a6e2adfebcb13f2d" + integrity sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg== + dependencies: + resolve-from "^5.0.0" + +resolve-from@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" + integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== + +resolve.exports@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/resolve.exports/-/resolve.exports-2.0.2.tgz#f8c934b8e6a13f539e38b7098e2e36134f01e800" + integrity sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg== + +resolve@^1.20.0: + version "1.22.8" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.8.tgz#b6c87a9f2aa06dfab52e3d70ac8cde321fa5a48d" + integrity sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw== + dependencies: + is-core-module "^2.13.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +semver@7.x, semver@^7.5.3, semver@^7.5.4: + version "7.6.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.2.tgz#1e3b34759f896e8f14d6134732ce798aeb0c6e13" + integrity sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w== + +semver@^6.3.0, semver@^6.3.1: + version "6.3.1" + resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" + integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +signal-exit@^3.0.3, signal-exit@^3.0.7: + version "3.0.7" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" + integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== + +sisteransi@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" + integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== + +slash@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" + integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== + +source-map-support@0.5.13: + version "0.5.13" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.13.tgz#31b24a9c2e73c2de85066c0feb7d44767ed52932" + integrity sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w== + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + +source-map@^0.6.0, source-map@^0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +sprintf-js@~1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" + integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== + +stack-utils@^2.0.3: + version "2.0.6" + resolved "https://registry.yarnpkg.com/stack-utils/-/stack-utils-2.0.6.tgz#aaf0748169c02fc33c8232abccf933f54a1cc34f" + integrity sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ== + dependencies: + escape-string-regexp "^2.0.0" + +string-length@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/string-length/-/string-length-4.0.2.tgz#a8a8dc7bd5c1a82b9b3c8b87e125f66871b6e57a" + integrity sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ== + dependencies: + char-regex "^1.0.2" + strip-ansi "^6.0.0" + +string-similarity@4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/string-similarity/-/string-similarity-4.0.4.tgz#42d01ab0b34660ea8a018da8f56a3309bb8b2a5b" + integrity sha512-/q/8Q4Bl4ZKAPjj8WerIBJWALKkaPRfrvhfF8k/B23i4nzrlRj2/go1m90In7nG/3XDSbOo0+pu6RvCTM9RGMQ== + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-bom@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-4.0.0.tgz#9c3505c1db45bcedca3d9cf7a16f5c5aa3901878" + integrity sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w== + +strip-final-newline@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" + integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== + +strip-json-comments@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" + integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== + +supports-color@^5.3.0: + version "5.5.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" + integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== + dependencies: + has-flag "^3.0.0" + +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +supports-color@^8.0.0: + version "8.1.1" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== + dependencies: + has-flag "^4.0.0" + +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + +test-exclude@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/test-exclude/-/test-exclude-6.0.0.tgz#04a8698661d805ea6fa293b6cb9e63ac044ef15e" + integrity sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w== + dependencies: + "@istanbuljs/schema" "^0.1.2" + glob "^7.1.4" + minimatch "^3.0.4" + +tmpl@1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.5.tgz#8683e0b902bb9c20c4f726e3c0b69f36518c07cc" + integrity sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw== + +to-fast-properties@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" + integrity sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog== + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + +ts-jest@29.0.3: + version "29.0.3" + resolved "https://registry.yarnpkg.com/ts-jest/-/ts-jest-29.0.3.tgz#63ea93c5401ab73595440733cefdba31fcf9cb77" + integrity sha512-Ibygvmuyq1qp/z3yTh9QTwVVAbFdDy/+4BtIQR2sp6baF2SJU/8CKK/hhnGIDY2L90Az2jIqTwZPnN2p+BweiQ== + dependencies: + bs-logger "0.x" + fast-json-stable-stringify "2.x" + jest-util "^29.0.0" + json5 "^2.2.1" + lodash.memoize "4.x" + make-error "1.x" + semver "7.x" + yargs-parser "^21.0.1" + +tslib@^2.4.0: + version "2.6.2" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" + integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== + +type-detect@4.0.8: + version "4.0.8" + resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" + integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== + +type-fest@^0.21.3: + version "0.21.3" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" + integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== + +undici-types@~5.26.4: + version "5.26.5" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" + integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== + +update-browserslist-db@^1.0.13: + version "1.0.15" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.15.tgz#60ed9f8cba4a728b7ecf7356f641a31e3a691d97" + integrity sha512-K9HWH62x3/EalU1U6sjSZiylm9C8tgq2mSvshZpqc7QE69RaA2qjhkW2HlNA0tFpEbtyFz7HTqbSdN4MSwUodA== + dependencies: + escalade "^3.1.2" + picocolors "^1.0.0" + +url@0.11.0: + version "0.11.0" + resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1" + integrity sha512-kbailJa29QrtXnxgq+DdCEGlbTeYM2eJUxsz6vjZavrCYPMIFHMKQmSKYAIuUK2i7hgPm28a8piX5NTUtM/LKQ== + dependencies: + punycode "1.3.2" + querystring "0.2.0" + +v8-to-istanbul@^9.0.1: + version "9.2.0" + resolved "https://registry.yarnpkg.com/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz#2ed7644a245cddd83d4e087b9b33b3e62dfd10ad" + integrity sha512-/EH/sDgxU2eGxajKdwLCDmQ4FWq+kpi3uCmBGpw1xJtnAxEjlD8j8PEiGWpCIMIs3ciNAgH0d3TTJiUkYzyZjA== + dependencies: + "@jridgewell/trace-mapping" "^0.3.12" + "@types/istanbul-lib-coverage" "^2.0.1" + convert-source-map "^2.0.0" + +w3c-keyname@^2.2.0: + version "2.2.8" + resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz#7b17c8c6883d4e8b86ac8aba79d39e880f8869c5" + integrity sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ== + +walker@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.8.tgz#bd498db477afe573dc04185f011d3ab8a8d7653f" + integrity sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ== + dependencies: + makeerror "1.0.12" + +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== + +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== + +write-file-atomic@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/write-file-atomic/-/write-file-atomic-4.0.2.tgz#a9df01ae5b77858a027fd2e80768ee433555fcfd" + integrity sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg== + dependencies: + imurmurhash "^0.1.4" + signal-exit "^3.0.7" + +ws@^8.14.2: + version "8.17.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.0.tgz#d145d18eca2ed25aaf791a183903f7be5e295fea" + integrity sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow== + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yallist@^3.0.2: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" + integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== + +yargs-parser@^21.0.1, yargs-parser@^21.1.1: + version "21.1.1" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" + integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== + +yargs@^17.3.1: + version "17.7.2" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" + integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== + dependencies: + cliui "^8.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.1.1" + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +zod@3.21.4: + version "3.21.4" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.21.4.tgz#10882231d992519f0a10b5dd58a38c9dabbb64db" + integrity sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw== diff --git a/dev.sh b/dev.sh new file mode 100755 index 0000000..7cf1c59 --- /dev/null +++ b/dev.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +ENV=${1:-dev} +PROJECT=$2 +case $ENV in + dev) + NEXT_ENV=DEV ;; + prod) + NEXT_ENV=PROD ;; + *) + echo "Invalid environment; must be dev or prod." + exit 1 +esac + +DIR=web +export IS_MANIFOLD_LOVE=true +source .env + +npx dotenv -e .env -- npx concurrently \ + -n API,NEXT,TS \ + -c white,magenta,cyan \ + "cross-env ENV=$NEXT_ENV yarn --cwd=backend/api dev" \ + "cross-env NEXT_PUBLIC_API_URL=localhost:8088 NEXT_PUBLIC_FIREBASE_ENV=$NEXT_ENV yarn --cwd=$DIR serve" \ + "cross-env yarn --cwd=$DIR ts-watch" + diff --git a/docs/knowledge.md b/docs/knowledge.md new file mode 100644 index 0000000..526c3d1 --- /dev/null +++ b/docs/knowledge.md @@ -0,0 +1,465 @@ +# Project Knowledge + +This is a dating app. It has questions and profiles. + +## Development Workflow + +- Current progress in todos.md +- Global packages should be installed with `bun`, not npm or yarn. +- Do not run build commands after each change unless specifically requested by me. Mostly, I'm typically already running a dev server. + - HUMAN TODO: create a yarn precommit function that does basic formatting that normally happens on save, as well as lint and type checks. + +## Project Structure + +- next.js react tailwind frontend `/web` + + - broken down into pages, components, hooks, lib + +- express node api server `/backend/api` +- one off scripts, like migrations `/backend/scripts` +- supabase postgres. schema in `/backend/supabase` + - generated by `/backend/scripts/regen-schema.ts` + - supbase-generated types in `/backend/supabase/schema.ts` +- files shared between backend directories `/backend/shared` + + - anything in backend can import from shared, but not vice versa + +- files shared between the frontend and backend `/common` + - common has lots of type definitions for our data structures, like Contract and User. It also contains many useful utility functions. We try not to add package dependencies to common. web and backend are allowed to import from common, but not vice versa + +## Deployment + +- The project has both dev and prod environments +- Project is on GCP (Google Cloud Platform). Deployment handled by terraform +- Project ID is `compass-130ba` + +## Code Guidelines + +--- + +Here's an example component from web in our style: + +```tsx +import clsx from 'clsx' +import Link from 'next/link' + +import { isAdminId, isModId } from 'common/envs/constants' +import { type Headline } from 'common/news' +import { EditNewsButton } from 'web/components/news/edit-news-button' +import { Carousel } from 'web/components/widgets/carousel' +import { useUser } from 'web/hooks/use-user' +import { track } from 'web/lib/service/analytics' +import { DashboardEndpoints } from 'web/components/dashboard/dashboard-page' +import { removeEmojis } from 'common/util/string' + +export function HeadlineTabs(props: { + headlines: Headline[] + currentSlug: string + endpoint: DashboardEndpoints + hideEmoji?: boolean + notSticky?: boolean + className?: string +}) { + const { headlines, endpoint, currentSlug, hideEmoji, notSticky, className } = + props + const user = useUser() + + return ( +

+ + {headlines.map(({ id, slug, title }) => ( + + ))} + {user && } + {user && (isAdminId(user.id) || isModId(user.id)) && ( + + )} + +
+ ) +} +``` + +--- + +We prefer to have many smaller components that each represent one logical unit, rather than one very large component that does everything. Then we compose and reuse the components. + +It's best to export the main component at the top of the file. We also try to name the component the same as the file name (headline-tabs.tsx) so that it's easy to find. + +Here's another example in `home.tsx` that calls our api. We have an endpoint called 'headlines', which is being cached by NextJS: + +```ts +import { api } from 'web/lib/api/api' +// More imports... + +export async function getStaticProps() { + try { + const headlines = await api('headlines', {}) + return { + props: { + headlines, + revalidate: 30 * 60, // 30 minutes + }, + } + } catch (err) { + return { props: { headlines: [] }, revalidate: 60 } + } +} + +export default function Home(props: { headlines: Headline[] }) { ... } +``` + +--- + +If we are calling the API on the client, prefer using the `useAPIGetter` hook: + +```ts +export const YourTopicsSection = (props: { + user: User + className?: string +}) => { + const { user, className } = props + const { data, refresh } = useAPIGetter('get-followed-groups', { + userId: user.id, + }) + const followedGroups = data?.groups ?? [] + ... +``` + +This stores the result in memory, and allows you to call refresh() to get an updated version. + +--- + +We frequently use `usePersistentInMemoryState` or `usePersistentLocalState` as an alternative to `useState`. These cache data. Most of the time you want in memory caching so that navigating back to a page will preserve the same state and appear to load instantly. + +Here's the definition of usePersistentInMemoryState: + +```ts +export const usePersistentInMemoryState = (initialValue: T, key: string) => { + const [state, setState] = useStateCheckEquality( + safeJsonParse(store[key]) ?? initialValue + ) + + useEffect(() => { + const storedValue = safeJsonParse(store[key]) ?? initialValue + setState(storedValue as T) + }, [key]) + + const saveState = useEvent((newState: T | ((prevState: T) => T)) => { + setState((prevState) => { + const updatedState = isFunction(newState) ? newState(prevState) : newState + store[key] = JSON.stringify(updatedState) + return updatedState + }) + }) + + return [state, saveState] as const +} +``` + +--- + +For live updates, we use websockets. In `use-api-subscription.ts`, we have this hook: + +```ts +export function useApiSubscription(opts: SubscriptionOptions) { + useEffect(() => { + const ws = client + if (ws != null) { + if (opts.enabled ?? true) { + ws.subscribe(opts.topics, opts.onBroadcast).catch(opts.onError) + return () => { + ws.unsubscribe(opts.topics, opts.onBroadcast).catch(opts.onError) + } + } + } + }, [opts.enabled, JSON.stringify(opts.topics)]) +} +``` + +In `use-bets`, we have this hook to get live updates with useApiSubscription: + +```ts +export const useContractBets = ( + contractId: string, + opts?: APIParams<'bets'> & { enabled?: boolean } +) => { + const { enabled = true, ...apiOptions } = { + contractId, + ...opts, + } + const optionsKey = JSON.stringify(apiOptions) + + const [newBets, setNewBets] = usePersistentInMemoryState( + [], + `${optionsKey}-bets` + ) + + const addBets = (bets: Bet[]) => { + setNewBets((currentBets) => { + const uniqueBets = sortBy( + uniqBy([...currentBets, ...bets], 'id'), + 'createdTime' + ) + return uniqueBets.filter((b) => !betShouldBeFiltered(b, apiOptions)) + }) + } + + const isPageVisible = useIsPageVisible() + + useEffect(() => { + if (isPageVisible && enabled) { + api('bets', apiOptions).then(addBets) + } + }, [optionsKey, enabled, isPageVisible]) + + useApiSubscription({ + topics: [`contract/${contractId}/new-bet`], + onBroadcast: (msg) => { + addBets(msg.data.bets as Bet[]) + }, + enabled, + }) + + return newBets +} +``` + +--- + +Here are all the topics we broadcast, from `backend/shared/src/websockets/helpers.ts` + +```ts +export function broadcastUpdatedPrivateUser(userId: string) { + // don't send private user info because it's private and anyone can listen + broadcast(`private-user/${userId}`, {}) +} + +export function broadcastUpdatedUser(user: Partial & { id: string }) { + broadcast(`user/${user.id}`, { user }) +} + +export function broadcastUpdatedComment(comment: Comment) { + broadcast(`user/${comment.onUserId}/comment`, { comment }) +} +``` + +--- + +We have our scripts in the directory `backend/scripts`. + +To write a script, run it inside the helper function called `runScript` that automatically fetches any secret keys and loads them into process.env. + +Example from `backend/scripts/manicode.ts` + +```ts +import { runScript } from 'run-script' + +runScript(async ({ pg }) => { + const userPrompt = process.argv[2] + // E.g.: + // I want to create a new page which shows off what's happening on manifold right now. Can you use our websocket api to get recent bets on markets and illustrate what's happening in a compelling and useful way? + if (!userPrompt) { + console.log('Please provide a prompt on what code to change.') + return + } + + await manicode(pg, userPrompt) +}) +``` + +Generally scripts should be run by me, especially if they modify backend state or schema. +But if you need to run a script, you can use `bun`. For example: + +```sh +bun run manicode.ts "Generate a page called cowp, which has cows that make noises!" +``` + +if that doesn't work, try + +```sh +bun x ts-node manicode.ts "Generate a page called cowp, which has cows that make noises!" +``` + +--- + +Our backend is mostly a set of endpoints. We create new endpoints by adding to the schema in `common/src/api/schema.ts`. + +E.g. Here is a hypothetical bet schema: + +```ts + bet: { + method: 'POST', + authed: true, + returns: {} as CandidateBet & { betId: string }, + props: z + .object({ + contractId: z.string(), + amount: z.number().gte(1), + replyToCommentId: z.string().optional(), + limitProb: z.number().gte(0.01).lte(0.99).optional(), + expiresAt: z.number().optional(), + // Used for binary and new multiple choice contracts (cpmm-multi-1). + outcome: z.enum(['YES', 'NO']).default('YES'), + //Multi + answerId: z.string().optional(), + dryRun: z.boolean().optional(), + }) + .strict(), + }, +``` + +Then, we define the bet endpoint in `backend/api/src/place-bet.ts` + +```ts +export const placeBet: APIHandler<'bet'> = async (props, auth) => { + const isApi = auth.creds.kind === 'key' + return await betsQueue.enqueueFn( + () => placeBetMain(props, auth.uid, isApi), + [props.contractId, auth.uid] + ) +} +``` + +And finally, you need to register the handler in `backend/api/src/routes.ts` + +```ts +import { placeBet } from './place-bet' +... + +const handlers = { + bet: placeBet, + ... +} +``` + +--- + +We have two ways to access our postgres database. + +```ts +import { db } from 'web/lib/supabase/db' + +db.from('lovers').select('*').eq('user_id', userId) +``` + +and + +```ts +import { createSupabaseDirectClient } from 'shared/supabase/init' + +const pg = createSupabaseDirectClient() +pg.oneOrNone>('select * from lovers where user_id = $1', [userId]) +``` + +The supabase client just uses the supabase client library, which is a wrapper around postgREST. It allows us to query and update the database directly from the frontend. + +`createSupabaseDirectClient` is used on the backend. it lets us specify sql strings to run directly on our database, using the pg-promise library. The client (code in web) does not have permission to do this. + +Another example using the direct client: + +```ts +export const getUniqueBettorIds = async ( + contractId: string, + pg: SupabaseDirectClient +) => { + const res = await pg.manyOrNone( + 'select distinct user_id from contract_bets where contract_id = $1', + [contractId] + ) + return res.map((r) => r.user_id as string) +} +``` + +(you may notice we write sql in lowercase) + +We have a few helper functions for updating and inserting data into the database. + +```ts +import { + buikInsert, + bulkUpdate, + bulkUpdateData, + bulkUpsert, + insert, + update, + updateData, +} from 'shared/supabase/utils' + +... + +const pg = createSupabaseDirectClient() + +// you are encouraged to use tryCatch for these +const { data, error } = await tryCatch( + insert(pg, 'lovers', { user_id: auth.uid, ...body }) +) + +if (error) throw APIError(500, 'Error creating lover: ' + error.message) + +await update(pg, 'lovers', 'user_id', { user_id: auth.uid, age: 99 }) + +await updateData(pg, 'private_users', { id: userId, notifications: { ... } }) +``` + +The sqlBuilder from `shared/supabase/sql-builder.ts` can be used to construct SQL queries with re-useable parts. All it does is sanitize and output sql query strings. It has several helper functions including: + +- `select`: Specifies the columns to select +- `from`: Specifies the table to query +- `where`: Adds WHERE clauses +- `orderBy`: Specifies the order of results +- `limit`: Limits the number of results +- `renderSql`: Combines all parts into a final SQL string + +Example usage: + +```typescript +const query = renderSql( + select('distinct user_id'), + from('contract_bets'), + where('contract_id = ${id}', { id }), + orderBy('created_time desc'), + limitValue != null && limit(limitValue) +) + +const res = await pg.manyOrNone(query) +``` + +Use these functions instead of string concatenation. + +### Misc coding tips + +We have many useful hooks that should be reused rather than rewriting them again. + +--- + +We prefer using lodash functions instead of reimplementing them with for loops: + +```ts +import { keyBy, uniq } from 'lodash' + +const betsByUserId = keyBy(bets, 'userId') +const betIds = uniq(bets, (b) => b.id) +``` + +--- + +Instead of Sets, consider using lodash's uniq function: + +```ts +const betIds = uniq([]) +for (const id of betIds) { + ... +} +``` diff --git a/app/favicon.ico b/favicon.ico similarity index 100% rename from app/favicon.ico rename to favicon.ico diff --git a/firebase.json b/firebase.json new file mode 100644 index 0000000..f41fee6 --- /dev/null +++ b/firebase.json @@ -0,0 +1,12 @@ +{ + "storage": [ + { + "bucket": "compass-130ba.firebasestorage.app", + "rules": "storage.rules" + }, + { + "bucket": "compass-130ba-private.firebasestorage.app", + "rules": "private-storage.rules" + } + ] +} diff --git a/migrate.sh b/migrate.sh new file mode 100644 index 0000000..64a461f --- /dev/null +++ b/migrate.sh @@ -0,0 +1,6 @@ +PGPASSWORD="eVxjrghGhtP9FpCc" psql \ + -h db.ltzepxnhhnrnvovqblfr.supabase.co \ + -p 5432 \ + -d postgres \ + -U postgres \ + -f migration.sql diff --git a/migration.sql b/migration.sql new file mode 100644 index 0000000..f19d1d2 --- /dev/null +++ b/migration.sql @@ -0,0 +1,22 @@ +BEGIN; +\i backend/supabase/functions.sql +\i backend/supabase/firebase.sql +\i backend/supabase/lovers.sql +\i backend/supabase/users.sql +\i backend/supabase/private_user_message_channels.sql +\i backend/supabase/private_user_message_channel_members.sql +\i backend/supabase/private_users.sql +\i backend/supabase/private_user_messages.sql +\i backend/supabase/private_user_seen_message_channels.sql +\i backend/supabase/love_answers.sql +\i backend/supabase/lover_comments.sql +\i backend/supabase/love_compatibility_answers.sql +\i backend/supabase/love_likes.sql +\i backend/supabase/love_questions.sql +\i backend/supabase/love_ships.sql +\i backend/supabase/love_stars.sql +\i backend/supabase/love_waitlist.sql +\i backend/supabase/user_events.sql +\i backend/supabase/user_notifications.sql +\i backend/supabase/functions_others.sql +COMMIT; diff --git a/.eslintrc.js b/old/.eslintrc.js similarity index 100% rename from .eslintrc.js rename to old/.eslintrc.js diff --git a/__mocks__/fileMock.js b/old/__mocks__/fileMock.js similarity index 100% rename from __mocks__/fileMock.js rename to old/__mocks__/fileMock.js diff --git a/__mocks__/styleMock.js b/old/__mocks__/styleMock.js similarity index 100% rename from __mocks__/styleMock.js rename to old/__mocks__/styleMock.js diff --git a/app/Header.tsx b/old/app/Header.tsx similarity index 100% rename from app/Header.tsx rename to old/app/Header.tsx diff --git a/app/about/page.tsx b/old/app/about/page.tsx similarity index 94% rename from app/about/page.tsx rename to old/app/about/page.tsx index 144f10f..61e18d7 100644 --- a/app/about/page.tsx +++ b/old/app/about/page.tsx @@ -94,7 +94,7 @@ export default function About() {
Develop the App

The source code and instructions are available on GitHub.

View Code @@ -104,6 +104,13 @@ export default function About() {

Support our not-for-profit infrastructure.

+ + Donate on Patreon + `, + // from: `Compass <${process.env.EMAIL_FROM!}>`, // to: email, // subject: 'Verify your email', // html: emailHtml, diff --git a/app/api/auth/verify-email/route.ts b/old/app/api/auth/verify-email/route.ts similarity index 100% rename from app/api/auth/verify-email/route.ts rename to old/app/api/auth/verify-email/route.ts diff --git a/app/api/download/route.ts b/old/app/api/download/route.ts similarity index 100% rename from app/api/download/route.ts rename to old/app/api/download/route.ts diff --git a/app/api/interests/route.ts b/old/app/api/interests/route.ts similarity index 100% rename from app/api/interests/route.ts rename to old/app/api/interests/route.ts diff --git a/app/api/profile/route.ts b/old/app/api/profile/route.ts similarity index 100% rename from app/api/profile/route.ts rename to old/app/api/profile/route.ts diff --git a/app/api/profiles/[id]/route.ts b/old/app/api/profiles/[id]/route.ts similarity index 100% rename from app/api/profiles/[id]/route.ts rename to old/app/api/profiles/[id]/route.ts diff --git a/app/api/profiles/count/route.ts b/old/app/api/profiles/count/route.ts similarity index 100% rename from app/api/profiles/count/route.ts rename to old/app/api/profiles/count/route.ts diff --git a/app/api/profiles/prompts/route.ts b/old/app/api/profiles/prompts/route.ts similarity index 100% rename from app/api/profiles/prompts/route.ts rename to old/app/api/profiles/prompts/route.ts diff --git a/app/api/profiles/route.ts b/old/app/api/profiles/route.ts similarity index 100% rename from app/api/profiles/route.ts rename to old/app/api/profiles/route.ts diff --git a/app/api/upload/route.ts b/old/app/api/upload/route.ts similarity index 100% rename from app/api/upload/route.ts rename to old/app/api/upload/route.ts diff --git a/app/api/user/update-profile/route.ts b/old/app/api/user/update-profile/route.ts similarity index 100% rename from app/api/user/update-profile/route.ts rename to old/app/api/user/update-profile/route.ts diff --git a/app/auth/error/page.tsx b/old/app/auth/error/page.tsx similarity index 100% rename from app/auth/error/page.tsx rename to old/app/auth/error/page.tsx diff --git a/app/auth/verification-success/page.tsx b/old/app/auth/verification-success/page.tsx similarity index 100% rename from app/auth/verification-success/page.tsx rename to old/app/auth/verification-success/page.tsx diff --git a/app/complete-profile/page.tsx b/old/app/complete-profile/page.tsx similarity index 100% rename from app/complete-profile/page.tsx rename to old/app/complete-profile/page.tsx diff --git a/app/components/dropdown.tsx b/old/app/components/dropdown.tsx similarity index 100% rename from app/components/dropdown.tsx rename to old/app/components/dropdown.tsx diff --git a/old/app/favicon.ico b/old/app/favicon.ico new file mode 100644 index 0000000..21a0a4b Binary files /dev/null and b/old/app/favicon.ico differ diff --git a/app/favicon_color.ico b/old/app/favicon_color.ico similarity index 100% rename from app/favicon_color.ico rename to old/app/favicon_color.ico diff --git a/app/globals.css b/old/app/globals.css similarity index 100% rename from app/globals.css rename to old/app/globals.css diff --git a/app/layout.tsx b/old/app/layout.tsx similarity index 97% rename from app/layout.tsx rename to old/app/layout.tsx index 17b9101..c5fb510 100644 --- a/app/layout.tsx +++ b/old/app/layout.tsx @@ -30,7 +30,7 @@ export default function RootLayout(