diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 000000000000..48fb1e6e6f49 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,93 @@ +You are a Senior Front-End Developer who is working on a Social media platform and an Expert in below mentioned tech stack. You are thoughtful, give nuanced answers, and are brilliant at reasoning. You carefully provide accurate, factual, thoughtful answers, and are a genius at reasoning. + +- Follow the user’s requirements carefully & to the letter. +- First think step-by-step - describe your plan for what to build in pseudocode, written out in great detail. +- Confirm, then write code! +- Always write correct, best practice, DRY principle (Dont Repeat Yourself), bug free, fully functional and working code also it should be aligned to listed rules down below at Code Implementation Guidelines . +- Focus on easy and readability code, over being performant. +- Fully implement all requested functionality. +- Leave NO todo’s, placeholders or missing pieces. +- Ensure code is complete! Verify thoroughly finalised. +- Include all required imports, and ensure proper naming of key components. +- Be concise Minimize any other prose. +- If you think there might not be a correct answer, you say so. +- If you do not know the answer, say so, instead of guessing. + +### Tech Stack + +The user asks questions about the following coding languages: + +- ReactJS +- NextJS +- JavaScript +- TypeScript +- HeadlessUI +- TailwindCSS +- HTML +- CSS +- Apollo GraphQL +- Radix +- Express +- Prisma with Postgres +- Clickhouse +- Redis +- AWS S3, SES +- OpenAI +- Zod +- Zustand +- Prosekit +- Remark and Rehype + +### Code Implementation Guidelines + +Follow these rules when you write code: + +- Use early returns whenever possible to make the code more readable. +- In React always use export default at end of the file +- Always use Tailwind classes for styling HTML elements; avoid using CSS or tags. +- Use descriptive variable and function/const names. Also, event functions should be named with a “handle” prefix, like “handleClick” for onClick and “handleKeyDown” for onKeyDown. +- Implement accessibility features on elements. For example, a tag should have a tabindex=“0”, aria-label, on:click, and on:keydown, and similar attributes. +- Use consts aka arrow function instead of functions, for example, “const toggle = () =>”. Also, define a type if possible. + +### Monorepo Management + +- Follow best practices using pnpm workspaces for monorepo setups. +- Ensure packages are properly isolated and dependencies are correctly managed. +- Use shared configurations and scripts where appropriate. +- Utilize the workspace structure as defined in the root `package.json`. + +### Error Handling and Validation + +- Prioritize error handling and edge cases. +- Handle errors and edge cases at the beginning of functions. +- Use early returns for error conditions to avoid deep nesting. +- Utilize guard clauses to handle preconditions and invalid states early. +- Implement proper error logging and user-friendly error messages. +- Use custom error types or factories for consistent error handling. + +### State Management and Data Fetching + +- Use Zustand for state management. +- Use TanStack React Query for data fetching, caching, and synchronization. +- Use Apollo Client for GraphQL data fetching. +- Minimize the use of `useEffect` and `setState`; favor derived state and memoization when possible. + +### TypeScript and Zod Usage + +- Use TypeScript for all code; prefer interfaces over types for object shapes. +- Use component name as interface name. Example: `Account` component should have `AccountProps` interface. +- Utilize Zod for schema validation and type inference. +- Avoid enums; use literal types or maps instead. +- Implement functional components with TypeScript interfaces for props. + +### Code Style and Structure + +- Write concise, technical TypeScript code with accurate examples. +- Use functional and declarative programming patterns; avoid classes. +- Prefer iteration and modularization over code duplication. +- Use camelCase for variables, functions, and methods. +- Use UPPERCASE for environment variables. +- Start each function with a verb, Example: `handleClick`, `handleKeyDown`, `handleChange`, etc. +- Use verbs for boolean variables. Example: `isLoading`, `hasError`, `canDelete`, etc. +- Use complete words instead of abbreviations and correct spelling. +- Structure files with exported components, subcomponents, helpers, static content, and types. diff --git a/.deepsource.toml b/.deepsource.toml deleted file mode 100644 index 11e6fd80a8a1..000000000000 --- a/.deepsource.toml +++ /dev/null @@ -1,22 +0,0 @@ -version = 1 - -exclude_patterns = [ - "node_modules", - ".next", - ".github", - ".husky", - ".vscode", - "apps/web/src/generated/types.ts", - "yarn.lock" -] - -[[analyzers]] -name = "javascript" -enabled = true - - [analyzers.meta] - plugins = ["react"] - -[[transformers]] -name = "prettier" -enabled = true diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000000..fb4f38928dcd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,3 @@ +node_modules +**/node_modules +.env.example \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index 580cb4bf1482..000000000000 --- a/.eslintrc.js +++ /dev/null @@ -1,8 +0,0 @@ -module.exports = { - root: true, - settings: { - next: { - rootDir: ['apps/*/'] - } - } -}; diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 28fcd27699a0..cb78c21872bd 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1 +1 @@ -custom: ['https://lenster.xyz/donate'] +github: [heyxyz] diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml deleted file mode 100644 index 41f5c28d35a9..000000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ /dev/null @@ -1,70 +0,0 @@ -name: "Bug Report" -description: "Report a reproducible bug in the Lenster" -labels: "needs review" -body: - - type: markdown - attributes: - value: Thanks for taking the time to file a bug report! Please fill out this form as completely as possible. - - - type: checkboxes - attributes: - label: Is there an existing issue for this? - description: Please search to see if an issue already exists for the bug you encountered. - options: - - label: I have searched the existing issues - required: true - - - type: textarea - attributes: - label: Current Behavior - description: A concise description of what you're experiencing. - validations: - required: false - - - type: textarea - attributes: - label: Expected Behavior - description: A concise description of what you expected to happen. - validations: - required: false - - - type: textarea - attributes: - label: Steps To Reproduce - description: Steps or code snippets to reproduce the behavior. - validations: - required: false - - - type: dropdown - attributes: - label: What platform(s) does this occur on? - multiple: true - options: - - Web - - Mobile - validations: - required: true - - - type: dropdown - attributes: - label: What browser(s) does this occur on? - multiple: true - options: - - Chrome - - Firefox - - Brave - - Safari - - Edge - - Others - validations: - required: true - - - type: textarea - attributes: - label: Anything else? - description: | - Screenshots? Anything that will give us more context about the issue you are encountering! - - Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in. - validations: - required: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml deleted file mode 100644 index 2864c0f407e6..000000000000 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ /dev/null @@ -1,11 +0,0 @@ -name: "Feature Request" -description: "Request a feature for Lenster" -labels: "needs review" -body: - - type: textarea - attributes: - label: Summary - description: Describe the feature in 1 or 2 sentences - placeholder: Clearly describe what you want to see in Lenster. - validations: - required: true diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index 3d44e198d79b..000000000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,39 +0,0 @@ -## What does this PR do? - - - -Fixes # (issue) - - - -## Type of change - - - -- [ ] Bug fix (non-breaking change which fixes an issue) -- [ ] New feature (non-breaking change which adds functionality) -- [ ] Enhancement (non-breaking small changes to existing functionality) -- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) -- [ ] This change requires a documentation update - -## How should this be tested? - - - -- [ ] Test A -- [ ] Test B - -## Checklist - - - -- I haven't read the [contributing guide](https://github.com/lensterxyz/lenster/blob/main/CONTRIBUTING.md) -- My code doesn't follow the style guidelines of this project -- I haven't performed a self-review of my own code and corrected any misspellings -- I haven't commented my code, particularly in hard-to-understand areas -- I haven't checked if my PR needs changes to the documentation -- I haven't checked if my changes generate no new warnings -- I haven't added tests that prove my fix is effective or that my feature works -- I haven't checked if new and existing unit tests pass locally with my changes diff --git a/.github/actions/docker/action.yml b/.github/actions/docker/action.yml new file mode 100644 index 000000000000..3d85c1a0a4ce --- /dev/null +++ b/.github/actions/docker/action.yml @@ -0,0 +1,52 @@ +name: Docker Build and Push +description: "Build and push Docker image" + +inputs: + dockerhub_username: + description: "Dockerhub username" + required: true + default: "" + dockerhub_token: + description: "Dockerhub token" + required: true + default: "" + image_name: + description: "Image name" + required: true + default: "" + docker_file: + description: "Docker file" + required: true + default: "" + tag_name: + description: "Tag name" + required: false + default: "latest" + build_args: + description: "Build args" + required: false + default: "" + +runs: + using: "composite" + steps: + - name: Set up QEMU 🐳 + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx 🐳 + uses: docker/setup-buildx-action@v3 + + - name: Login to Docker Hub 🔑 + uses: docker/login-action@v3 + with: + username: ${{ inputs.dockerhub_username }} + password: ${{ inputs.dockerhub_token }} + + - name: Build and push image 🚀 + uses: docker/build-push-action@v6 + with: + context: . + file: ${{ inputs.docker_file }} + push: true + tags: heyxyz/${{ inputs.image_name }}:${{ inputs.tag_name }} + build-args: ${{ inputs.build_args }} diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 2a0184b690bb..000000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,39 +0,0 @@ -version: 2 -updates: - # Apps - - package-ecosystem: "npm" - directory: "apps/web" - target-branch: "main" - schedule: - interval: "daily" - - - package-ecosystem: "npm" - directory: "apps/api" - target-branch: "main" - schedule: - interval: "daily" - - # Packages - - package-ecosystem: "npm" - directory: "packages/abis" - target-branch: "main" - schedule: - interval: "daily" - - - package-ecosystem: "npm" - directory: "packages/data" - target-branch: "main" - schedule: - interval: "daily" - - - package-ecosystem: "npm" - directory: "packages/eslint-config-weblint" - target-branch: "main" - schedule: - interval: "daily" - - - package-ecosystem: "npm" - directory: "packages/lens" - target-branch: "main" - schedule: - interval: "daily" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 47181fada087..000000000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - -jobs: - ci: - name: CI - runs-on: ubuntu-latest - steps: - - name: Checkout 🚪 - uses: actions/checkout@v3 - - - name: Setup node 🍀 - uses: actions/setup-node@v3 - with: - node-version-file: '.nvmrc' - cache: 'yarn' - - - name: Install 📦 - run: yarn install --frozen-lockfile - - - name: GraphQL Codegen 🕸 - run: yarn codegen - - - name: Typecheck 🔡 - run: yarn typecheck - - - name: Lint 🪩 - run: yarn lint - - - name: Prettier ✨ - run: yarn prettier diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index 94d4fcbe0f7b..000000000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: CodeQL - -on: - push: - branches: [main] - pull_request: - branches: [main] - -jobs: - analyze: - name: Analyze - runs-on: ubuntu-latest - permissions: - actions: read - contents: read - security-events: write - - strategy: - fail-fast: false - matrix: - language: ['javascript', 'typescript'] - - steps: - - uses: actions/checkout@v3 - - - name: Initialize CodeQL - uses: github/codeql-action/init@v2 - with: - languages: ${{ matrix.language }} - - - name: Autobuild - uses: github/codeql-action/autobuild@v2 - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 diff --git a/.github/workflows/deploy-docker.yml b/.github/workflows/deploy-docker.yml new file mode 100644 index 000000000000..72b8442bdb4d --- /dev/null +++ b/.github/workflows/deploy-docker.yml @@ -0,0 +1,122 @@ +# name: Deploy Docker Images + +# env: +# API_RAILWAY_SERVICE_ID: 4a2a1bfb-e499-4c71-bf7f-d9ad47443c31 +# CRON_RAILWAY_SERVICE_ID: 348ba788-3282-4f27-9967-ca04eea9ac4b +# OG_RAILWAY_SERVICE_ID: 76d31e4b-218d-4f82-974d-f2c8e91480e2 + +# on: +# push: +# branches: [main] +# workflow_dispatch: + +# concurrency: +# group: ${{ github.workflow }}-${{ github.ref }} +# cancel-in-progress: true + +# jobs: +# check-changes: +# runs-on: ubuntu-latest +# outputs: +# api-changed: ${{ steps.filter.outputs.api }} +# cron-changed: ${{ steps.filter.outputs.cron }} +# og-changed: ${{ steps.filter.outputs.og }} +# steps: +# - name: Checkout 🚪 +# uses: actions/checkout@v4 + +# - name: Check for changes 🔍 +# id: filter +# uses: dorny/paths-filter@v3 +# with: +# filters: | +# api: +# - 'apps/api/**' +# - 'packages/**' +# cron: +# - 'apps/cron/**' +# - 'packages/**' +# og: +# - 'apps/og/**' +# - 'packages/**' + +# api: +# needs: check-changes +# if: needs.check-changes.outputs.api-changed == 'true' +# name: heyxyz/api:latest +# runs-on: ubuntu-latest +# steps: +# - name: Checkout 🚪 +# uses: actions/checkout@v4 + +# - name: Build and push heyxyz/api:latest 🚀 +# uses: ./.github/actions/docker +# with: +# dockerhub_username: ${{ secrets.DOCKERHUB_USERNAME }} +# dockerhub_token: ${{ secrets.DOCKERHUB_TOKEN }} +# image_name: api +# tag_name: 'latest' +# docker_file: ./apps/api/Dockerfile + +# - name: Trigger API Deployment 🚀 +# uses: indiesdev/curl@v1 +# id: deploy +# with: +# url: 'https://redeploy.heyxyz.workers.dev' +# params: '{ "secret": "${{ secrets.SECRET }}", "service": "${{ env.API_RAILWAY_SERVICE_ID }}" }' +# method: 'GET' +# timeout: 30000 + +# cron: +# needs: check-changes +# if: needs.check-changes.outputs.cron-changed == 'true' +# name: heyxyz/cron:latest +# runs-on: ubuntu-latest +# steps: +# - name: Checkout 🚪 +# uses: actions/checkout@v4 + +# - name: Build and push heyxyz/cron:latest 🚀 +# uses: ./.github/actions/docker +# with: +# dockerhub_username: ${{ secrets.DOCKERHUB_USERNAME }} +# dockerhub_token: ${{ secrets.DOCKERHUB_TOKEN }} +# image_name: cron +# tag_name: 'latest' +# docker_file: ./apps/cron/Dockerfile + +# - name: Trigger Cron Deployment 🚀 +# uses: indiesdev/curl@v1 +# id: deploy +# with: +# url: 'https://redeploy.heyxyz.workers.dev' +# params: '{ "secret": "${{ secrets.SECRET }}", "service": "${{ env.CRON_RAILWAY_SERVICE_ID }}" }' +# method: 'GET' +# timeout: 30000 + +# og: +# needs: check-changes +# if: needs.check-changes.outputs.og-changed == 'true' +# name: heyxyz/og:latest +# runs-on: ubuntu-latest +# steps: +# - name: Checkout 🚪 +# uses: actions/checkout@v4 + +# - name: Build and push heyxyz/og:latest 🚀 +# uses: ./.github/actions/docker +# with: +# dockerhub_username: ${{ secrets.DOCKERHUB_USERNAME }} +# dockerhub_token: ${{ secrets.DOCKERHUB_TOKEN }} +# image_name: og +# tag_name: 'latest' +# docker_file: ./apps/og/Dockerfile + +# - name: Trigger OG Deployment 🚀 +# uses: indiesdev/curl@v1 +# id: deploy +# with: +# url: 'https://redeploy.heyxyz.workers.dev' +# params: '{ "secret": "${{ secrets.SECRET }}", "service": "${{ env.OG_RAILWAY_SERVICE_ID }}" }' +# method: 'GET' +# timeout: 30000 diff --git a/.gitignore b/.gitignore index 05f3303f5f84..a6435d48c561 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,15 @@ out/ .swc build +# expo +.expo/ +expo-env.d.ts +credentials.json +ios/ +android/ +*.ipa +*.apk + # misc .DS_Store *.pem @@ -30,11 +39,14 @@ yarn-error.log* .env.production.local .env -# turbo -.turbo - -# vercel -.vercel - # typescript *.tsbuildinfo + +# JetBrains IDE +.idea + +# Service Workers +sw.js + +# Node build artifacts +dist diff --git a/.husky/pre-commit b/.husky/pre-commit index 4b7700279981..ca60ff9d983d 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,5 +1,2 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" - -yarn lint -yarn typecheck +pnpm run biome:check +pnpm typecheck diff --git a/.nvmrc b/.nvmrc index b6a7d89c68e0..209e3ef4b624 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -16 +20 diff --git a/.pierre/ci/biome.ts b/.pierre/ci/biome.ts new file mode 100644 index 000000000000..e9d1de60a2b4 --- /dev/null +++ b/.pierre/ci/biome.ts @@ -0,0 +1,9 @@ +import { run } from "pierre"; + +export const label = "Biome Lint"; + +export default async () => { + await run("pnpm biome:check", { + label: "Checking Biome Lint" + }); +}; diff --git a/.pierre/ci/build.ts b/.pierre/ci/build.ts new file mode 100644 index 000000000000..4e4e395c77db --- /dev/null +++ b/.pierre/ci/build.ts @@ -0,0 +1,9 @@ +import { run } from "pierre"; + +export const label = "Production Build"; + +export default async () => { + await run("pnpm build", { + label: "Building production bundle" + }); +}; diff --git a/.pierre/ci/depcheck.ts b/.pierre/ci/depcheck.ts new file mode 100644 index 000000000000..c8be4617d099 --- /dev/null +++ b/.pierre/ci/depcheck.ts @@ -0,0 +1,9 @@ +import { run } from "pierre"; + +export const label = "Check Dependencies"; + +export default async () => { + await run("pnpm dep:check", { + label: "Checking dependencies" + }); +}; diff --git a/.pierre/ci/migrate.ts b/.pierre/ci/migrate.ts new file mode 100644 index 000000000000..b3abb4628ade --- /dev/null +++ b/.pierre/ci/migrate.ts @@ -0,0 +1,37 @@ +import { Icons, annotate, run } from "pierre"; + +export const label = "Migrate DB"; + +const migrateProductionDb = async ({ branch }) => { + if (branch.name !== "main") { + await run('echo "Skipping DB Migration on non-main branches 🚫"', { + label: "Skipping DB Migration" + }); + + annotate({ + color: "fg", + label: "Skipped Production DB Migration", + icon: Icons.Table + }); + } else { + await run("cd packages/db && pnpm prisma:migrate", { + label: "Migrating Production DB", + env: { DATABASE_URL: process.env.PRODUCTION_DATABASE_URL as string } + }); + } +}; + +const migrateTestDb = async () => { + await run("cd packages/db && pnpm prisma:migrate", { + label: "Migrating Test DB", + env: { DATABASE_URL: process.env.TEST_DATABASE_URL as string } + }); + + annotate({ + color: "fg", + label: "Test DB Migration Complete", + icon: Icons.Table + }); +}; + +export default [migrateProductionDb, migrateTestDb]; diff --git a/.pierre/ci/swh.ts b/.pierre/ci/swh.ts new file mode 100644 index 000000000000..04eff83bf161 --- /dev/null +++ b/.pierre/ci/swh.ts @@ -0,0 +1,16 @@ +import { run } from "pierre"; + +export const label = "Save to SWH"; + +export default async ({ branch }) => { + if (branch.name !== "main") { + await run('echo "Skipping SWH on non-main branches 🚫"', { + label: "Skipping SWH" + }); + } else { + await run( + "curl -X POST https://archive.softwareheritage.org/api/1/origin/save/git/url/https://github.com/heyxyz/hey", + { label: "Saving to SWH" } + ); + } +}; diff --git a/.pierre/ci/test.ts b/.pierre/ci/test.ts new file mode 100644 index 000000000000..77281a2ea4f3 --- /dev/null +++ b/.pierre/ci/test.ts @@ -0,0 +1,47 @@ +import { run } from "pierre"; + +export const label = "Test"; + +const apiTest = async () => { + const DATABASE_URL = process.env.TEST_DATABASE_URL as string; + + await run("cd packages/db && pnpm redis:flush", { + label: "Flushing Test Redis" + }); + + await run("cd packages/db && pnpm prisma:seed", { + label: "Seeding Test DB", + env: { DATABASE_URL } + }); + + await run("cd apps/api && pnpm test", { + label: "Running API tests", + env: { DATABASE_URL } + }); +}; + +const ogTest = async () => { + await run("cd apps/og && pnpm test", { + label: "Running OG tests" + }); +}; + +const webTest = async () => { + await run("cd apps/web && pnpm test", { + label: "Running Web tests" + }); +}; + +const dataTest = async () => { + await run("cd packages/data && pnpm test", { + label: "Running Data tests" + }); +}; + +const helpersTest = async () => { + await run("cd packages/helpers && pnpm test", { + label: "Running Helpers tests" + }); +}; + +export default [apiTest, ogTest, webTest, dataTest, helpersTest]; diff --git a/.pierre/ci/typecheck.ts b/.pierre/ci/typecheck.ts new file mode 100644 index 000000000000..b16ba7452bab --- /dev/null +++ b/.pierre/ci/typecheck.ts @@ -0,0 +1,43 @@ +import { Icons, annotate, run } from "pierre"; + +// biome-ignore lint/suspicious/noControlCharactersInRegex: +const ANSI_COLOR_REGEX = /\x1b\[[0-9;]*m/g; +const ERROR_REGEX = /^(.*?): (.*?):([0-9]+):[0-9]+ - (.*?): (.*)$/; + +export const label = "Typecheck"; + +export const options = { + failOnAnnotations: true +}; + +export default async () => { + const { stdmerged, exitCode } = await run("pnpm typecheck", { + label: "Running Typechecks", + allowAnyCode: true + }); + + const lines = stdmerged.split("\n"); + + for (const line of lines) { + const cleanedLine = line.replace(ANSI_COLOR_REGEX, ""); + const match = cleanedLine.match(ERROR_REGEX); + + if (match == null) { + continue; + } + + const [, packageName, filepath, lineNo, errorCode, errorMessage] = match; + const packagePath = packageName.replace(" typecheck", ""); + + annotate({ + icon: Icons.CiFailed, + color: "red", + filename: `${packagePath}/${filepath}`, + line: Number.parseInt(lineNo), + label: "Typescript failure", + description: `${errorMessage} (${errorCode})` + }); + } + + return exitCode; +}; diff --git a/.prettierignore b/.prettierignore deleted file mode 100644 index 69351b423b0c..000000000000 --- a/.prettierignore +++ /dev/null @@ -1,5 +0,0 @@ -node_modules -out -.next -.turbo -.github diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 558bbe902f0e..000000000000 --- a/.prettierrc +++ /dev/null @@ -1,9 +0,0 @@ -{ - "arrowParens": "always", - "bracketSpacing": true, - "semi": true, - "useTabs": false, - "trailingComma": "none", - "singleQuote": true, - "printWidth": 110 -} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000000..609757dd1681 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,17 @@ +{ + "recommendations": [ + "formulahendry.auto-close-tag", + "mikestead.dotenv", + "biomejs.biome", + "GitHub.github-vscode-theme", + "NomicFoundation.hardhat-solidity", + "eamodio.gitlens", + "wix.vscode-import-cost", + "mquandalle.graphql", + "PKief.material-icon-theme", + "Prisma.prisma", + "christian-kohler.npm-intellisense", + "bradlc.vscode-tailwindcss", + "aaron-bond.better-comments" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000000..d23bc3b87520 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,26 @@ +{ + "editor.formatOnSave": true, + "editor.defaultFormatter": "biomejs.biome", + "[javascript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[markdown]": { + "editor.defaultFormatter": "DavidAnson.vscode-markdownlint" + }, + "editor.codeActionsOnSave": { + "quickfix.biome": "explicit", + "source.organizeImports": "explicit" + }, + "search.exclude": { + "**/node_modules": true + }, + "git.autofetch": true, + "git.confirmSync": false, + "git.enableSmartCommit": true +} diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md index 909e77d4fe3a..4577bc2c818e 100644 --- a/CODE_OF_CONDUCT.md +++ b/CODE_OF_CONDUCT.md @@ -34,14 +34,14 @@ This Code of Conduct applies both within project spaces and in public spaces whe ## Enforcement -Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at legal@lenster.xyz. All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at . All complaints will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. ## Attribution -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [homepage]: https://www.contributor-covenant.org -For answers to common questions about this code of conduct, see https://www.contributor-covenant.org/faq +For answers to common questions about this code of conduct, see diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index 49bb9e2199a9..000000000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,60 +0,0 @@ -# Contributing to Lenster 🌸 - -👍🎉 Thank you for your interest in contributing to Lenster! 🎉👍 - -Lenster is an open-source project maintained by [Lenster team](https://github.com/lensterxyz). We appreciate your interest and efforts to contribute to Lenster. - -Anyone can be a contributor. Either you found a typo, or you have an awesome feature request you could implement, we encourage you to create a Merge Request. - -All efforts to contribute are highly appreciated, we recommend you talk to a maintainer prior to spending a lot of time making a merge request that may not align with the project roadmap. - -## Values - -- **Be the change** - Don't wait for anything, if you want something ship it but make sure it is sustainable for the long term. -- **Pride in product** - We ask ourselves whether we will use the feature if it goes live before creating it. -- **Diversity** - We welcome differences, and listen before we speak. It proportionally leads to extraordinary results and experiences. - -## Open Development & Community Driven - -Lenster is an open-source project. See the [LICENSE](https://github.com/lensterxyz/lenster/blob/main/LICENSE) file for licensing information. All the work done is available on GitHub. - -The maintainers and the contributors send merge requests which go through the same validation process. - -## Feature Requests - -Feature Requests by the community are highly encouraged. Please feel free to create an [issue](https://github.com/lensterxyz/lenster/issues/new) or to upvote 👍 [an existing issue](https://github.com/lensterxyz/lenster/issues) in the GitHub. - -## Code of Conduct - -This project and everyone participating in it are governed by the [Code of Conduct](https://github.com/lensterxyz/lenster/blob/main/CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please read the [full text](https://github.com/lensterxyz/lenster/blob/main/CODE_OF_CONDUCT.md) so that you can read which actions may or may not be tolerated. - -## Bugs - -We are using [GitHub Issues](https://github.com/lensterxyz/lenster/issues) to manage our public bugs. We keep a close eye on this so before filing a new issue, try to make sure the problem does not already exist. - ---- - -## Submitting a Merge Request - -- Merge Requests should be raised for any change and it will be approved by a maintainer before merging. -- The latest changes are always in `main` branch, so please create your branch from `main`. -- If you’ve fixed a bug or added code that should be tested, add the tests and then link the corresponding issue in either your commit or your PR. -- Run `yarn lint` before committing to make resolving conflicts easier (VSCode users, check out this extension to fix lint issues in development) -- We encourage you to test your changes, and if you have the opportunity, please make those tests part of the Merge Request. -- If you add new functionality, please provide the corresponding documentation as well and make it part of the Merge Request. -- The Merge Request should be raised against main branch. - -## Contribution Prerequisites - -- You have [Node](https://nodejs.org/en/) at >= v14. -- You are familiar with Git. - -## Development Workflow - -First of all, you need to check if you're satisfying the `Contribution Prerequisites` - -Then, please follow the instructions in [LOCAL_SETUP_GUIDE](docs/setup.md). - ---- - -## Happy Contributing 🥳 diff --git a/LICENSE b/LICENSE index f5994bcde2b4..2620751481bd 100644 --- a/LICENSE +++ b/LICENSE @@ -1,21 +1,661 @@ -MIT License - -Copyright (c) 2022 Lenster - -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. + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + +Copyright (C) 2007 Free Software Foundation, Inc. +Everyone is permitted to copy and distribute verbatim copies +of this license document, but changing it is not allowed. + + Preamble + +The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + +The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + +When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + +Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + +A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + +The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + +An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + +The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + +0. Definitions. + +"This License" refers to version 3 of the GNU Affero General Public License. + +"Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + +"The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + +To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + +A "covered work" means either the unmodified Program or a work based +on the Program. + +To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + +To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + +An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + +1. Source Code. + +The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + +A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + +The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + +The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + +The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + +The Corresponding Source for a work in source code form is that +same work. + +2. Basic Permissions. + +All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + +You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + +Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + +3. Protecting Users' Legal Rights From Anti-Circumvention Law. + +No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + +When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + +4. Conveying Verbatim Copies. + +You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + +You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + +5. Conveying Modified Source Versions. + +You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + +A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + +6. Conveying Non-Source Forms. + +You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + +A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + +A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + +"Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + +If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + +The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + +Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + +7. Additional Terms. + +"Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + +When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + +Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + +All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + +If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + +Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + +8. Termination. + +You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + +However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + +Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + +Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + +9. Acceptance Not Required for Having Copies. + +You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + +10. Automatic Licensing of Downstream Recipients. + +Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + +An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + +You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + +11. Patents. + +A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + +A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + +Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + +In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + +If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + +If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + +A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + +Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + +12. No Surrender of Others' Freedom. + +If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + +13. Remote Network Interaction; Use with the GNU General Public License. + +Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + +Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + +14. Revised Versions of this License. + +The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + +If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + +Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + +15. Disclaimer of Warranty. + +THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + +16. Limitation of Liability. + +IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + +17. Interpretation of Sections 15 and 16. + +If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + +If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + +To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) 2024 Hey + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + +If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + +You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/README.md b/README.md index a279bdac03c2..48b2051b0aa0 100644 --- a/README.md +++ b/README.md @@ -1,89 +1,153 @@ -
- Lenster Logo -

Lenster

- Decentralized, and permissionless social media app 🌿 -
-
- -
-
- lenster.xyz » -

- Discord - • - Issues -
- -## 🌿 About Lenster - -Lenster is a decentralized and permissionless social media app built with [Lens Protocol](http://lens.xyz) 🌿 - -## ✅ Community - -For a place to have open discussions on features, voice your ideas, or get help with general questions please visit our community at [Discord](https://lenster.xyz/discord). - -## 🤝 Contributing - -We encourage you to contribute to Lenster! Please check out the [Contributing guide](CONTRIBUTING.md) for guidelines about how to proceed. - -## ⚙️ Setup - -### Using Local Environment - -```sh -cd apps/web +# Hey Monorepo + +## Requirements + +- [Node.js](https://nodejs.org/en/download/) (v18 or higher) - The backbone of our project, make sure you have this installed. +- [pnpm](https://pnpm.io/installation) - Our trusty package manager, because who doesn't love faster installs? +- [Postgres App](https://postgresapp.com/) - Our database of choice, because data needs a cozy home. +- [Redis](https://redis.io/download) - The speedy in-memory data store, for when you need things done in a flash. + +## Installation + +We harness the power of [pnpm workspaces](https://pnpm.io/workspaces) to keep our monorepo running smoother than a freshly buttered pancake. + +### Clone the repository + +Clone the Hey monorepo to your local machine: + +```bash +git clone git@git.pierre.co:/repos/hey/hey.git +``` + +### Install NVM (Node Version Manager) and pnpm + +Rocking a macOS? You can grab both with Homebrew, like a true brew master: + +```bash +brew install nvm pnpm +``` + +### Install Node.js + +Use `nvm` to summon the magical version of Node.js you need: + +```bash +nvm install +``` + +### Install dependencies + +Teleport yourself to the root of the repository and let pnpm sprinkle its dependency magic: + +```bash +pnpm install +``` + +### Create a `.env` file + +Channel your inner wizard and conjure up a `.env` file from the `.env.example` template for every package and app that needs it. Don't forget to sprinkle in the necessary environment variables! + +```bash cp .env.example .env -yarn install -yarn dev ``` -and visit http://localhost:4783 +Don't forget to play copycat and repeat this `.env` file creation for every package and app that needs it. Consistency is key! + +### Start the application + +When all the stars align and everything is in place, kick off the application in development mode: + +```bash +pnpm dev +``` + +## Build and Test + +### Build the application + +Ready to build the application? Just run this command: + +```bash +pnpm build +``` + +### Test the application + +Want to run tests while you're developing? Here's how you do it: + +```bash +pnpm test +``` + +## Periodic Tasks + +### Remove unused exports and helpers + +We use `ts-prune` to hunt down and eliminate unused exports and helpers lurking in our codebase. Just a heads-up: you'll need to run this task manually for each package and app. Happy pruning! + +```bash +cd apps/web; npx ts-prune -i generated.ts +``` + +### Update dependencies + +Time to give our dependencies a makeover! We rely on the magical powers of `pnpm` to keep everything up-to-date and looking sharp. + +```bash +script/clean-branches +script/update-dependencies +``` + +### Update lock file + +We trust `pnpm` to keep our lock file fresh and fabulous! + +```bash +script/clean-branches +script/update-lock-file +``` + +## Other tools you might like + +### Ripgrep + +We use [Ripgrep](https://github.com/BurntSushi/ripgrep) to search for text in the codebase. It's like `grep` and `ag` had a baby, and that baby grew up to be a speed demon! + +Install it via Homebrew: + +```bash +brew install ripgrep +``` + +Search for text in the codebase: + +```bash +rg "const Verified" +``` + +### Bundle Analyzer + +In `apps/web`, we've got a bundle analyzer that spills the beans on the size and contents of our production bundles. It's like having X-ray vision for your code! + +To generate this output, run: + +```bash +cd apps/web +ANALYZE=true pnpm build +``` + +Fire up this command to build the `apps/web` project and watch as three browser windows magically pop open, each showcasing bundle details for node, edge, and client bundles. The client bundle is the superhero for page performance, while all bundles play a vital role in development and build performance. -## 🤝 Supporting Repos +## Code of Conduct -- [Lenster Assets](https://github.com/lensterxyz/assets) - Static assets hosted in Vercel edge CDN -- [Lenster Sitemap](https://github.com/lensterxyz/sitemap) - List of sitemap for SEO -- [Lenster Utils](https://github.com/lensterxyz/utils) - Util APIs and http rewrites +We kindly ask all contributors and team members to follow our [Code of Conduct](./CODE_OF_CONDUCT.md). Think of it as our community's golden rulebook - play nice and keep the good vibes flowing! -## 💕 Contributors +## License -We love contributors! Feel free to contribute to this project but please read the [Contributing Guidelines](CONTRIBUTING.md) before opening an issue or PR so you understand the branching strategy and local development environment. +This project is open-sourced under the **AGPL-3.0** license. For all the nitty-gritty details, check out the [LICENSE](./LICENSE) file. It's a real page-turner! - - - +## P.S -## ⚖️ License +We 💖 you to the moon and back! Your support is like a never-ending supply of coffee for our code. Thank you for making Hey the most awesome place in the universe! -Lenster is open-sourced software licensed under the © [MIT](LICENSE). +🌸 diff --git a/SECURITY.md b/SECURITY.md index 98acf8dd6237..5423d85304de 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,12 +1,12 @@ # Security -Contact: security@lenster.xyz +Contact: -At Lenster, we consider the security of our systems a top priority. But no matter how much effort we put into system security, there can still be vulnerabilities present. +At Hey, we consider the security of our systems a top priority. But no matter how much effort we put into system security, there can still be vulnerabilities present. If you discover a vulnerability, we would like to know about it so we can take steps to address it as quickly as possible. We would like to ask you to help us better protect our clients and our systems. -## Out of scope vulnerabilities: +## Out of scope vulnerabilities - Clickjacking on pages with no sensitive actions. - Unauthenticated/logout/login CSRF. @@ -18,16 +18,16 @@ If you discover a vulnerability, we would like to know about it so we can take s - Lack of Secure or HTTP only flag on non-sensitive cookies - Deadlinks -## Please do the following: +## Please do the following -- E-mail your findings to [security@lenster.xyz](mailto:security@lenster.xyz). +- E-mail your findings to [support@hey.xyz](mailto:support@hey.xyz). - Do not run automated scanners on our infrastructure or dashboard. If you wish to do this, contact us and we will set up a sandbox for you. - Do not take advantage of the vulnerability or problem you have discovered, for example by downloading more data than necessary to demonstrate the vulnerability or deleting or modifying other people's data, - Do not reveal the problem to others until it has been resolved, - Do not use attacks on physical security, social engineering, distributed denial of service, spam or applications of third parties, - Do provide sufficient information to reproduce the problem, so we will be able to resolve it as quickly as possible. Usually, the IP address or the URL of the affected system and a description of the vulnerability will be sufficient, but complex vulnerabilities may require further explanation. -## What we promise: +## What we promise - We will respond to your report within 3 business days with our evaluation of the report and an expected resolution date, - If you have followed the instructions above, we will not take any legal action against you in regard to the report, diff --git a/apps/api/.env.example b/apps/api/.env.example index 48cef9fae646..5b19959799cf 100644 --- a/apps/api/.env.example +++ b/apps/api/.env.example @@ -1,8 +1,17 @@ -# mainnet, testnet, staging, sandbox or staging-sandbox -NEXT_PUBLIC_LENS_NETWORK="mainnet" -NEXT_PUBLIC_DATADOG_API_KEY="" -NEXT_PUBLIC_DATADOG_APPLICATION_KEY="" -BUNDLR_PRIVATE_KEY="" +DATABASE_URL="" +LENS_DATABASE_PASSWORD="" +CLICKHOUSE_URL="http://clickhouse.hey.xyz:8123" +CLICKHOUSE_PASSWORD="" +SECRET="secret" EVER_ACCESS_KEY="" EVER_ACCESS_SECRET="" -NEXT_PUBLIC_EVER_BUCKET_NAME="" +PRIVATE_KEY="1d65a3183f35ecef73ce8f7d47920d58abdf3766debc2ff0b4c653b7633707fd" # Testnet private key without funds +ADMIN_PRIVATE_KEY="" +AWS_ACCESS_KEY_ID="" +AWS_SECRET_ACCESS_KEY="" +SLACK_WEBHOOK_URL="" +OPENAI_API_KEY="" + +# Test Lens access tokens +TEST_AUTH_TOKEN="" +TEST_SUSPENDED_AUTH_TOKEN="" diff --git a/apps/api/.eslintrc.js b/apps/api/.eslintrc.js deleted file mode 100644 index ac8818912f23..000000000000 --- a/apps/api/.eslintrc.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - root: true, - extends: ['weblint'] -}; diff --git a/apps/api/.prettierignore b/apps/api/.prettierignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/apps/api/.prettierignore @@ -0,0 +1 @@ +dist diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 000000000000..186a27410158 --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,41 @@ +# Base image +FROM node:18-alpine AS base +RUN apk add --no-cache libc6-compat + +# Installer stage +FROM base AS installer +WORKDIR /app + +# Install pnpm globally +RUN npm install -g pnpm + +# Copy all files to the build context +COPY . . + +# Install dependencies +RUN pnpm install --frozen-lockfile + +# Prune dev dependencies to reduce image size +RUN pnpm prune --prod + +# Runner stage +FROM base AS runner +WORKDIR /app + +# Install pnpm globally +RUN npm install -g pnpm + +# Add non-root user for better security +RUN addgroup --system --gid 1001 hey +RUN adduser --system --uid 1001 app + +USER app + +# Copy built application and production dependencies from builder stage +COPY --from=installer /app . + +# Expose the port +EXPOSE 4784 + +# Command to run the app +CMD sleep 3 && pnpm --filter @hey/api run start diff --git a/apps/api/env.d.ts b/apps/api/env.d.ts new file mode 100644 index 000000000000..8b19b68bd71c --- /dev/null +++ b/apps/api/env.d.ts @@ -0,0 +1,19 @@ +declare namespace NodeJS { + interface ProcessEnv { + ADMIN_PRIVATE_KEY: string; + AWS_ACCESS_KEY_ID: string; + AWS_SECRET_ACCESS_KEY: string; + CLICKHOUSE_PASSWORD: string; + CLICKHOUSE_URL: string; + DATABASE_URL: string; + EVER_ACCESS_KEY: string; + EVER_ACCESS_SECRET: string; + LENS_DATABASE_PASSWORD: string; + PRIVATE_KEY: string; + SECRET: string; + SLACK_WEBHOOK_URL: string; + TEST_AUTH_TOKEN: string; + TEST_SUSPENDED_AUTH_TOKEN: string; + OPENAI_API_KEY: string; + } +} diff --git a/apps/api/index.html b/apps/api/index.html new file mode 100644 index 000000000000..9903c57637c1 --- /dev/null +++ b/apps/api/index.html @@ -0,0 +1 @@ +Hey API diff --git a/apps/api/next-env.d.ts b/apps/api/next-env.d.ts deleted file mode 100644 index 4f11a03dc6cc..000000000000 --- a/apps/api/next-env.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -/// -/// - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/apps/api/next.config.js b/apps/api/next.config.js deleted file mode 100644 index 26528b3c2d23..000000000000 --- a/apps/api/next.config.js +++ /dev/null @@ -1,22 +0,0 @@ -/** @type {import('next').NextConfig} */ -const withTM = require('next-transpile-modules')(['data']); - -module.exports = withTM({ - reactStrictMode: false, - trailingSlash: false, - async rewrites() { - return [{ source: '/:path*', destination: '/api/:path*' }]; - }, - async headers() { - return [ - { - source: '/:path*', - headers: [ - { key: 'Access-Control-Allow-Origin', value: '*' }, - { key: 'Access-Control-Max-Age', value: '1728000' }, - { key: 'Access-Control-Allow-Headers', value: 'Content-Type' } - ] - } - ]; - } -}); diff --git a/apps/api/package.json b/apps/api/package.json index 76b964904bb5..d47ae4ae0277 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,33 +1,63 @@ { - "name": "api", + "name": "@hey/api", "version": "0.0.0", "private": true, + "license": "AGPL-3.0", "scripts": { - "dev": "next dev --port 6969", - "build": "next build", - "start": "next start", - "typecheck": "tsc --noEmit", - "lint": "eslint . --ext .js,.ts,.tsx" + "dev": "nodemon -w src -x tsx src/server.ts", + "dev:silent": "pnpm dev --silent > /dev/null 2>&1", + "start": "NODE_ENV=production tsx src/server.ts", + "test": "start-server-and-test dev:silent http://localhost:4784 test:dev", + "test:dev": "vitest run", + "typecheck": "tsc --pretty" }, "dependencies": { - "@apollo/client": "^3.7.1", - "@aws-sdk/client-s3": "^3.211.0", - "@aws-sdk/client-sts": "^3.212.0", - "@bundlr-network/client": "^0.9.0", - "axios": "^1.1.3", - "dotenv": "^16.0.3", - "next": "^13.0.2", - "next-transpile-modules": "^10.0.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "uuid": "^9.0.0" + "@aws-sdk/client-s3": "^3.700.0", + "@aws-sdk/client-ses": "^3.699.0", + "@aws-sdk/client-sts": "^3.699.0", + "@hey/abis": "workspace:*", + "@hey/data": "workspace:*", + "@hey/db": "workspace:*", + "@hey/helpers": "workspace:*", + "@hey/indexer": "workspace:*", + "@json2csv/plainjs": "^7.0.6", + "@lens-protocol/metadata": "^1.2.0", + "apollo-utilities": "^1.3.4", + "axios": "^1.7.8", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^5.0.0", + "express-file-routing": "^3.0.3", + "express-rate-limit": "^7.4.1", + "express-session": "^1.18.1", + "fast-xml-parser": "^4.4.1", + "graphql": "^16.9.0", + "linkedom": "^0.18.5", + "openai": "^4.73.1", + "rate-limit-redis": "^4.2.0", + "request-ip": "^3.3.0", + "tsx": "^4.19.2", + "ua-parser-js": "2.0.0", + "urlcat": "^3.1.0", + "uuid": "^11.0.2", + "viem": "^2.21.51", + "zod": "^3.23.8" }, "devDependencies": { - "@types/react": "^18.0.23", - "@types/react-dom": "^18.0.9", - "@types/uuid": "^8.3.4", - "data": "*", - "tsconfig": "*", - "typescript": "^4.9.3" + "@faker-js/faker": "^9.2.0", + "@hey/config": "workspace:*", + "@hey/types": "workspace:*", + "@types/cors": "^2.8.17", + "@types/express": "^5.0.0", + "@types/express-session": "^1.18.0", + "@types/node": "^22.10.0", + "@types/request-ip": "^0.0.41", + "@types/ua-parser-js": "^0.7.39", + "@types/uuid": "^10.0.0", + "nodemon": "^3.1.7", + "start-server-and-test": "^2.0.8", + "ts-node": "^10.9.2", + "typescript": "^5.7.2", + "vitest": "^2.1.5" } } diff --git a/apps/api/src/apollo.ts b/apps/api/src/apollo.ts deleted file mode 100644 index 4d12d84f5d7d..000000000000 --- a/apps/api/src/apollo.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { ApolloClient, from, HttpLink, InMemoryCache } from '@apollo/client'; -import { API_URL } from 'data/constants'; - -const httpLink = new HttpLink({ - uri: API_URL, - fetchOptions: 'no-cors', - fetch -}); - -const client = new ApolloClient({ - link: from([httpLink]), - cache: new InMemoryCache({}) -}); - -export default client; diff --git a/apps/api/src/constants.ts b/apps/api/src/constants.ts deleted file mode 100644 index 678c0dfe0fbe..000000000000 --- a/apps/api/src/constants.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Datadog -export const DATADOG_TOKEN = process.env.NEXT_PUBLIC_DATADOG_API_KEY ?? ''; -export const DATADOG_APPLICATION_KEY = process.env.NEXT_PUBLIC_DATADOG_APPLICATION_KEY ?? ''; - -// Bundlr -export const BUNDLR_CURRENCY = 'matic'; -export const BUNDLR_NODE_URL = 'https://node2.bundlr.network'; diff --git a/apps/api/src/helpers/catchedError.ts b/apps/api/src/helpers/catchedError.ts new file mode 100644 index 000000000000..69ef1d04fc83 --- /dev/null +++ b/apps/api/src/helpers/catchedError.ts @@ -0,0 +1,15 @@ +import logger from "@hey/helpers/logger"; +import type { Response } from "express"; + +const catchedError = (res: Response, error: any, status?: number) => { + const statusCode = status || 500; + logger.error(error); + + return res.status(statusCode).json({ + error: statusCode < 500 ? "client_error" : "server_error", + message: error.message, + success: false + }); +}; + +export default catchedError; diff --git a/apps/api/src/helpers/constants.ts b/apps/api/src/helpers/constants.ts new file mode 100644 index 000000000000..eda99fbb6b08 --- /dev/null +++ b/apps/api/src/helpers/constants.ts @@ -0,0 +1,14 @@ +export const HEY_USER_AGENT = "HeyBot/0.1 (like TwitterBot)"; +export const UNLEASH_API_URL = "https://unleash.hey.xyz/api/frontend"; + +// Cache +// Cache for 30 minutes +export const CACHE_AGE_30_MINS = "public, s-maxage=1800, max-age=1800"; +// Cache for 1 day +export const CACHE_AGE_1_DAY = "public, s-maxage=86400, max-age=86400"; +// Cache indefinitely +export const CACHE_AGE_INDEFINITE = + "public, s-maxage=31536000, max-age=31536000, immutable"; + +// Tests +export const SITEMAP_BATCH_SIZE = 50000; diff --git a/apps/api/src/helpers/email/sendEmail.ts b/apps/api/src/helpers/email/sendEmail.ts new file mode 100644 index 000000000000..74d7df421aff --- /dev/null +++ b/apps/api/src/helpers/email/sendEmail.ts @@ -0,0 +1,40 @@ +import { SESClient, SendEmailCommand } from "@aws-sdk/client-ses"; +import logger from "@hey/helpers/logger"; + +const sesClient = new SESClient({ + credentials: { + accessKeyId: process.env.AWS_ACCESS_KEY_ID, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY + }, + region: "us-west-2" +}); + +const sendEmail = async ({ + body, + recipient, + subject +}: { + body: string; + recipient: string; + subject: string; +}) => { + try { + const command = new SendEmailCommand({ + Destination: { ToAddresses: [recipient] }, + Message: { + Body: { Html: { Charset: "UTF-8", Data: body } }, + Subject: { Charset: "UTF-8", Data: subject } + }, + Source: "no-reply@hey.xyz" + }); + const response = await sesClient.send(command); + + return logger.info( + `Email sent to ${recipient} via SES - ${response.MessageId}` + ); + } catch (error) { + return logger.error(error as any); + } +}; + +export default sendEmail; diff --git a/apps/api/src/helpers/email/sendEmailToAccount.ts b/apps/api/src/helpers/email/sendEmailToAccount.ts new file mode 100644 index 000000000000..e23d50df7bec --- /dev/null +++ b/apps/api/src/helpers/email/sendEmailToAccount.ts @@ -0,0 +1,35 @@ +import prisma from "@hey/db/prisma/db/client"; +import logger from "@hey/helpers/logger"; +import sendEmail from "./sendEmail"; + +const sendEmailToAccount = async ({ + id, + body, + subject +}: { + id: string; + body: string; + subject: string; +}) => { + try { + const foundEmail = await prisma.email.findUnique({ where: { id } }); + + if (!foundEmail?.email) { + return logger.error(`sendEmailToAccount: Email not found for ${id}`); + } + + await sendEmail({ + body, + recipient: foundEmail?.email, + subject + }); + + return logger.info( + `sendEmailToAccount: Email sent to ${foundEmail?.email} - ${id}` + ); + } catch (error) { + return logger.error(error as any); + } +}; + +export default sendEmailToAccount; diff --git a/apps/api/src/helpers/ens/resolverAbi.ts b/apps/api/src/helpers/ens/resolverAbi.ts new file mode 100644 index 000000000000..949c4347ae63 --- /dev/null +++ b/apps/api/src/helpers/ens/resolverAbi.ts @@ -0,0 +1,16 @@ +export const resolverAbi = [ + { + inputs: [{ internalType: "contract ENS", name: "_ens", type: "address" }], + stateMutability: "nonpayable", + type: "constructor" + }, + { + inputs: [ + { internalType: "address[]", name: "addresses", type: "address[]" } + ], + name: "getNames", + outputs: [{ internalType: "string[]", name: "r", type: "string[]" }], + stateMutability: "view", + type: "function" + } +]; diff --git a/apps/api/src/helpers/frames/signFrameAction.ts b/apps/api/src/helpers/frames/signFrameAction.ts new file mode 100644 index 000000000000..575f6d296ee7 --- /dev/null +++ b/apps/api/src/helpers/frames/signFrameAction.ts @@ -0,0 +1,87 @@ +import LensEndpoint from "@hey/data/lens-endpoints"; +import axios from "axios"; +import { HEY_USER_AGENT } from "../constants"; + +/** + * Middleware to validate Lens access token for connections + * @param accessToken Incoming access token + * @param network Incoming network + * @returns Response + */ +const signFrameAction = async ( + request: { + actionResponse: string; + buttonIndex: number; + inputText: string; + profileId: string; + pubId: string; + specVersion: string; + state: string; + url: string; + }, + accessToken: string, + network: string +): Promise<{ + signature: string; + signedTypedData: { value: any }; +} | null> => { + const allowedNetworks = ["mainnet", "testnet"]; + + if (!network || !allowedNetworks.includes(network)) { + return null; + } + + const isMainnet = network === "mainnet"; + try { + const { data } = await axios.post( + isMainnet ? LensEndpoint.Mainnet : LensEndpoint.Testnet, + { + query: ` + mutation SignFrameAction($request: FrameLensManagerEIP712Request!) { + signFrameAction(request: $request) { + signature + signedTypedData { + value { + actionResponse + buttonIndex + deadline + inputText + profileId + pubId + specVersion + state + url + } + } + } + } + `, + variables: { + request: { + actionResponse: request.actionResponse, + buttonIndex: request.buttonIndex, + inputText: request.inputText, + profileId: request.profileId, + pubId: request.pubId, + specVersion: request.specVersion, + state: request.state, + url: request.url + } + } + }, + { + headers: { + "Content-Type": "application/json", + "User-agent": HEY_USER_AGENT, + "X-Access-Token": accessToken + } + } + ); + + return data.data.signFrameAction; + } catch { + return null; + } +}; + +export default signFrameAction; diff --git a/apps/api/src/helpers/getRpc.ts b/apps/api/src/helpers/getRpc.ts new file mode 100644 index 000000000000..32bfb6a3925f --- /dev/null +++ b/apps/api/src/helpers/getRpc.ts @@ -0,0 +1,13 @@ +import { LENS_TESTNET_RPCS, POLYGON_RPCS } from "@hey/data/rpcs"; +import type { FallbackTransport } from "viem"; +import { http, fallback } from "viem"; + +const getRpc = ({ mainnet }: { mainnet: boolean }): FallbackTransport => { + if (mainnet) { + return fallback(POLYGON_RPCS.map((rpc) => http(rpc))); + } + + return fallback(LENS_TESTNET_RPCS.map((rpc) => http(rpc))); +}; + +export default getRpc; diff --git a/apps/api/src/helpers/leafwatch/findEventKeyDeep.spec.ts b/apps/api/src/helpers/leafwatch/findEventKeyDeep.spec.ts new file mode 100644 index 000000000000..5c8c986cbb8a --- /dev/null +++ b/apps/api/src/helpers/leafwatch/findEventKeyDeep.spec.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from "vitest"; +import findEventKeyDeep from "./findEventKeyDeep"; + +describe("findEventKeyDeep", () => { + test("should return the key when the target is present at the first level", () => { + const obj = { eventA: "start", eventB: "middle", eventC: "end" }; + const target = "middle"; + const result = findEventKeyDeep(obj, target); + expect(result).toBe("eventB"); + }); + + test("should return the key when the target is present at a nested level", () => { + const obj = { + level1: { level2: { eventA: "start", eventB: "middle" }, eventC: "end" }, + eventD: "another" + }; + const target = "middle"; + const result = findEventKeyDeep(obj, target); + expect(result).toBe("eventB"); + }); + + test("should return the first key found when multiple keys have the same target value", () => { + const obj = { + eventA: "duplicate", + level1: { eventB: "duplicate", level2: { eventC: "duplicate" } }, + eventD: "unique" + }; + const target = "duplicate"; + const result = findEventKeyDeep(obj, target); + expect(result).toBe("eventA"); + }); + + test("should return null when the target is not present in the object", () => { + const obj = { eventA: "start", eventB: "middle", eventC: "end" }; + const target = "notFound"; + const result = findEventKeyDeep(obj, target); + expect(result).toBeNull(); + }); + + test("should return null when the object is empty", () => { + const obj = {}; + const target = "anyValue"; + const result = findEventKeyDeep(obj, target); + expect(result).toBeNull(); + }); + + test("should return the key when the target is present in an array within the object", () => { + const obj = { events: ["start", "middle", "end"], eventA: "another" }; + const target = "middle"; + const result = findEventKeyDeep(obj, target); + expect(result).toBe("1"); + }); + + test("should handle objects with mixed data types", () => { + const obj = { + eventA: "start", + eventB: 123, + eventC: null, + eventD: { eventE: "middle", eventF: true } + }; + const target = "middle"; + const result = findEventKeyDeep(obj, target); + expect(result).toBe("eventE"); + }); + + test("should handle deeply nested objects", () => { + const obj = { + level1: { level2: { level3: { level4: { eventA: "deepValue" } } } }, + eventB: "shallow" + }; + const target = "deepValue"; + const result = findEventKeyDeep(obj, target); + expect(result).toBe("eventA"); + }); + + test("should return null if the object contains circular references", () => { + const obj: any = { eventA: "start" }; + obj.level1 = obj; + const target = "start"; + const result = findEventKeyDeep(obj, target); + expect(result).toBe("eventA"); + }); +}); diff --git a/apps/api/src/helpers/leafwatch/findEventKeyDeep.ts b/apps/api/src/helpers/leafwatch/findEventKeyDeep.ts new file mode 100644 index 000000000000..b16908dd368c --- /dev/null +++ b/apps/api/src/helpers/leafwatch/findEventKeyDeep.ts @@ -0,0 +1,18 @@ +const findEventKeyDeep = (obj: any, target: string): null | string => { + for (const [key, value] of Object.entries(obj)) { + if (typeof value === "string" && value === target) { + return key; + } + + if (typeof value === "object" && value !== null) { + const result = findEventKeyDeep(value, target); + if (result) { + return result; + } + } + } + + return null; +}; + +export default findEventKeyDeep; diff --git a/apps/api/src/helpers/lens/getRates.ts b/apps/api/src/helpers/lens/getRates.ts new file mode 100644 index 000000000000..fa5237cd9f7c --- /dev/null +++ b/apps/api/src/helpers/lens/getRates.ts @@ -0,0 +1,29 @@ +import lensPg from "@hey/db/lensPg"; +import type { FiatRate } from "@hey/types/lens"; + +const getRates = async (): Promise => { + try { + const rates = await lensPg.query(` + SELECT ec.name AS name, + ec.symbol AS symbol, + ec.decimals AS decimals, + ec.currency AS address, + fc.price AS fiat + FROM fiat.conversion AS fc + JOIN enabled.currency AS ec ON fc.currency = ec.currency + WHERE fc.fiatsymbol = 'usd'; + `); + + return rates.map((row: any) => ({ + address: row.address.toLowerCase(), + decimals: row.decimals, + fiat: Number(row.fiat), + name: row.name, + symbol: row.symbol + })); + } catch { + return []; + } +}; + +export default getRates; diff --git a/apps/api/src/helpers/middlewares/rateLimiter.ts b/apps/api/src/helpers/middlewares/rateLimiter.ts new file mode 100644 index 000000000000..b2068cf71904 --- /dev/null +++ b/apps/api/src/helpers/middlewares/rateLimiter.ts @@ -0,0 +1,47 @@ +import redisClient from "@hey/db/redisClient"; +import getIp from "@hey/helpers/getIp"; +import sha256 from "@hey/helpers/sha256"; +import type { NextFunction, Request, Response } from "express"; +import rateLimit from "express-rate-limit"; +import RedisStore from "rate-limit-redis"; +import catchedError from "../catchedError"; + +const hashedIp = (req: Request): string => sha256(getIp(req)).slice(0, 25); + +const createRateLimiter = (window: number, max: number) => { + return rateLimit({ + handler: (req, res) => + catchedError( + res, + new Error(`Too many requests - ${req.path} - ${getIp(req)}`), + 429 + ), + keyGenerator: (req) => `${sha256(req.path).slice(0, 25)}:${hashedIp(req)}`, + legacyHeaders: false, + max, // Maximum number of requests allowed within the window + skip: () => !redisClient?.isReady, + standardHeaders: true, + store: redisClient + ? new RedisStore({ + prefix: "rate-limit:", + sendCommand: (...args: string[]) => + redisClient?.sendCommand(args) as any + }) + : undefined, + windowMs: window * 60 * 1000 // Time window in milliseconds + }); +}; + +export const rateLimiter = ({ + requests, + within +}: { + requests: number; + within: number; +}) => { + const rateLimiter = createRateLimiter(within, requests); + + return (req: Request, res: Response, next: NextFunction) => { + rateLimiter(req, res, next); + }; +}; diff --git a/apps/api/src/helpers/middlewares/validateHasCreatorToolsAccess.ts b/apps/api/src/helpers/middlewares/validateHasCreatorToolsAccess.ts new file mode 100644 index 000000000000..f3a2739cefff --- /dev/null +++ b/apps/api/src/helpers/middlewares/validateHasCreatorToolsAccess.ts @@ -0,0 +1,56 @@ +import { UNLEASH_API_TOKEN } from "@hey/data/constants"; +import { Errors } from "@hey/data/errors"; +import { FeatureFlag } from "@hey/data/feature-flags"; +import parseJwt from "@hey/helpers/parseJwt"; +import axios from "axios"; +import type { NextFunction, Request, Response } from "express"; +import catchedError from "../catchedError"; +import { HEY_USER_AGENT, UNLEASH_API_URL } from "../constants"; + +/** + * Middleware to validate if the account is staff + * @param req Incoming request + * @param res Response + * @param next Next function + */ +const validateHasCreatorToolsAccess = async ( + req: Request, + res: Response, + next: NextFunction +) => { + const idToken = req.headers["x-id-token"] as string; + if (!idToken) { + return catchedError(res, new Error(Errors.Unauthorized), 401); + } + + try { + const payload = parseJwt(idToken); + + const { data } = await axios.get(UNLEASH_API_URL, { + headers: { + Authorization: UNLEASH_API_TOKEN, + "User-Agent": HEY_USER_AGENT + }, + params: { + appName: "production", + environment: "production", + userId: payload.id + } + }); + + const flags = data.toggles; + const staffToggle = flags.find( + (toggle: any) => toggle.name === FeatureFlag.CreatorTools + ); + + if (staffToggle?.enabled && staffToggle?.variant?.featureEnabled) { + return next(); + } + + return catchedError(res, new Error(Errors.Unauthorized), 401); + } catch { + return catchedError(res, new Error(Errors.SomethingWentWrong)); + } +}; + +export default validateHasCreatorToolsAccess; diff --git a/apps/api/src/helpers/middlewares/validateIsGardener.ts b/apps/api/src/helpers/middlewares/validateIsGardener.ts new file mode 100644 index 000000000000..0548330bcfdd --- /dev/null +++ b/apps/api/src/helpers/middlewares/validateIsGardener.ts @@ -0,0 +1,56 @@ +import { UNLEASH_API_TOKEN } from "@hey/data/constants"; +import { Errors } from "@hey/data/errors"; +import { FeatureFlag } from "@hey/data/feature-flags"; +import parseJwt from "@hey/helpers/parseJwt"; +import axios from "axios"; +import type { NextFunction, Request, Response } from "express"; +import catchedError from "../catchedError"; +import { HEY_USER_AGENT, UNLEASH_API_URL } from "../constants"; + +/** + * Middleware to validate if the account is gardener + * @param req Incoming request + * @param res Response + * @param next Next function + */ +const validateIsGardener = async ( + req: Request, + res: Response, + next: NextFunction +) => { + const idToken = req.headers["x-id-token"] as string; + if (!idToken) { + return catchedError(res, new Error(Errors.Unauthorized), 401); + } + + try { + const payload = parseJwt(idToken); + + const { data } = await axios.get(UNLEASH_API_URL, { + headers: { + Authorization: UNLEASH_API_TOKEN, + "User-Agent": HEY_USER_AGENT + }, + params: { + appName: "production", + environment: "production", + userId: payload.id + } + }); + + const flags = data.toggles; + const gardenerToggle = flags.find( + (toggle: any) => toggle.name === FeatureFlag.Gardener + ); + + if (gardenerToggle?.enabled && gardenerToggle?.variant?.featureEnabled) { + return next(); + } + + return catchedError(res, new Error(Errors.Unauthorized), 401); + } catch { + return catchedError(res, new Error(Errors.SomethingWentWrong)); + } +}; + +export default validateIsGardener; diff --git a/apps/api/src/helpers/middlewares/validateIsStaff.ts b/apps/api/src/helpers/middlewares/validateIsStaff.ts new file mode 100644 index 000000000000..f2f07ab79cab --- /dev/null +++ b/apps/api/src/helpers/middlewares/validateIsStaff.ts @@ -0,0 +1,56 @@ +import { UNLEASH_API_TOKEN } from "@hey/data/constants"; +import { Errors } from "@hey/data/errors"; +import { FeatureFlag } from "@hey/data/feature-flags"; +import parseJwt from "@hey/helpers/parseJwt"; +import axios from "axios"; +import type { NextFunction, Request, Response } from "express"; +import catchedError from "../catchedError"; +import { HEY_USER_AGENT, UNLEASH_API_URL } from "../constants"; + +/** + * Middleware to validate if the account is staff + * @param req Incoming request + * @param res Response + * @param next Next function + */ +const validateIsStaff = async ( + req: Request, + res: Response, + next: NextFunction +) => { + const idToken = req.headers["x-id-token"] as string; + if (!idToken) { + return catchedError(res, new Error(Errors.Unauthorized), 401); + } + + try { + const payload = parseJwt(idToken); + + const { data } = await axios.get(UNLEASH_API_URL, { + headers: { + Authorization: UNLEASH_API_TOKEN, + "User-Agent": HEY_USER_AGENT + }, + params: { + appName: "production", + environment: "production", + userId: payload.id + } + }); + + const flags = data.toggles; + const staffToggle = flags.find( + (toggle: any) => toggle.name === FeatureFlag.Staff + ); + + if (staffToggle?.enabled && staffToggle?.variant?.featureEnabled) { + return next(); + } + + return catchedError(res, new Error(Errors.Unauthorized), 401); + } catch { + return catchedError(res, new Error(Errors.SomethingWentWrong)); + } +}; + +export default validateIsStaff; diff --git a/apps/api/src/helpers/middlewares/validateLensAccount.ts b/apps/api/src/helpers/middlewares/validateLensAccount.ts new file mode 100644 index 000000000000..b1c6d9b98fd8 --- /dev/null +++ b/apps/api/src/helpers/middlewares/validateLensAccount.ts @@ -0,0 +1,56 @@ +import { Errors } from "@hey/data/errors"; +import lensPg from "@hey/db/lensPg"; +import { getRedis, setRedis } from "@hey/db/redisClient"; +import parseJwt from "@hey/helpers/parseJwt"; +import type { NextFunction, Request, Response } from "express"; +import catchedError from "../catchedError"; + +/** + * Middleware to validate Lens account + * @param req Incoming request + * @param res Response + * @param next Next function + */ +const validateLensAccount = async ( + req: Request, + res: Response, + next: NextFunction +) => { + const idToken = req.headers["x-id-token"] as string; + if (!idToken) { + return catchedError(res, new Error(Errors.Unauthorized), 401); + } + + try { + const payload = parseJwt(idToken); + const cacheKey = `auth:${payload.id}`; + const cachedData = await getRedis(cacheKey); + + if (cachedData) { + return next(); + } + + const authentication = await lensPg.query( + ` + SELECT EXISTS ( + SELECT 1 FROM authentication.record + WHERE profile_id = $1 + AND authorization_id = $2 + LIMIT 1 + ) AS exists; + `, + [payload.id, payload.authorizationId] + ); + + if (authentication[0]?.exists) { + await setRedis(cacheKey, payload.authorizationId); + return next(); + } + + return catchedError(res, new Error(Errors.Unauthorized), 401); + } catch { + return catchedError(res, new Error(Errors.SomethingWentWrong)); + } +}; + +export default validateLensAccount; diff --git a/apps/api/src/helpers/middlewares/validateSecret.ts b/apps/api/src/helpers/middlewares/validateSecret.ts new file mode 100644 index 000000000000..8108d7275e33 --- /dev/null +++ b/apps/api/src/helpers/middlewares/validateSecret.ts @@ -0,0 +1,25 @@ +import { Errors } from "@hey/data/errors"; +import type { NextFunction, Request, Response } from "express"; +import catchedError from "../catchedError"; + +/** + * Middleware to validate secret + * @param req Incoming request + * @param res Response + * @param next Next function + */ +const validateSecret = (req: Request, res: Response, next: NextFunction) => { + const { secret } = req.query; + + try { + if (secret === process.env.SECRET) { + return next(); + } + + return catchedError(res, new Error(Errors.Unauthorized), 401); + } catch { + return catchedError(res, new Error(Errors.SomethingWentWrong)); + } +}; + +export default validateSecret; diff --git a/apps/api/src/helpers/oembed/getMetadata.spec.ts b/apps/api/src/helpers/oembed/getMetadata.spec.ts new file mode 100644 index 000000000000..234473934b2c --- /dev/null +++ b/apps/api/src/helpers/oembed/getMetadata.spec.ts @@ -0,0 +1,104 @@ +import getFavicon from "@hey/helpers/getFavicon"; +import axios from "axios"; +import { parseHTML } from "linkedom"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import getMetadata from "./getMetadata"; +import getProxyUrl from "./getProxyUrl"; +import generateIframe from "./meta/generateIframe"; +import getDescription from "./meta/getDescription"; +import getEmbedUrl from "./meta/getEmbedUrl"; +import getFrame from "./meta/getFrame"; +import getImage from "./meta/getImage"; +import getNft from "./meta/getNft"; +import getSite from "./meta/getSite"; +import getTitle from "./meta/getTitle"; + +// Mock the helper functions and axios +vi.mock("axios"); +vi.mock("linkedom", () => ({ parseHTML: vi.fn() })); +vi.mock("@hey/helpers/getFavicon"); +vi.mock("./meta/getDescription"); +vi.mock("./meta/getImage"); +vi.mock("./meta/getTitle"); +vi.mock("./meta/getSite"); +vi.mock("./meta/getFrame"); +vi.mock("./meta/getNft"); +vi.mock("./meta/generateIframe"); +vi.mock("./meta/getEmbedUrl"); +vi.mock("./getProxyUrl"); + +describe("getMetadata", () => { + const mockUrl = "https://example.com"; + const mockHTML = ""; + const mockDocument = { document: "mockDocument" }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("should return metadata with valid inputs", async () => { + (axios.get as any).mockResolvedValue({ data: mockHTML }); + (parseHTML as any).mockReturnValue({ document: mockDocument }); + (getDescription as any).mockReturnValue("Example description"); + (getImage as any).mockReturnValue("https://example.com/image.jpg"); + (getFavicon as any).mockReturnValue("https://example.com/favicon.ico"); + (getTitle as any).mockReturnValue("Example Title"); + (getSite as any).mockReturnValue("Example Site"); + (getFrame as any).mockReturnValue(""); + (getNft as any).mockReturnValue(null); + (generateIframe as any).mockReturnValue(""); + (getEmbedUrl as any).mockReturnValue("https://example.com/embed"); + (getProxyUrl as any).mockReturnValue("https://proxy.example.com/image.jpg"); + + const result = await getMetadata(mockUrl); + + expect(result).toEqual({ + description: "Example description", + favicon: "https://example.com/favicon.ico", + frame: "", + html: "", + image: "https://proxy.example.com/image.jpg", + lastIndexedAt: expect.any(String), + nft: null, + site: "Example Site", + title: "Example Title", + url: mockUrl + }); + }); + + test("should return null when axios request fails", async () => { + (axios.get as any).mockRejectedValue(new Error("Network error")); + const result = await getMetadata(mockUrl); + expect(result).toBeNull(); + }); + + test("should return metadata with fallback values when some helpers return undefined", async () => { + (axios.get as any).mockResolvedValue({ data: mockHTML }); + (parseHTML as any).mockReturnValue({ document: mockDocument }); + (getDescription as any).mockReturnValue(undefined); + (getImage as any).mockReturnValue(undefined); + (getFavicon as any).mockReturnValue(undefined); + (getTitle as any).mockReturnValue(undefined); + (getSite as any).mockReturnValue(undefined); + (getFrame as any).mockReturnValue(undefined); + (getNft as any).mockReturnValue(undefined); + (generateIframe as any).mockReturnValue(undefined); + (getEmbedUrl as any).mockReturnValue(undefined); + (getProxyUrl as any).mockReturnValue(undefined); + + const result = await getMetadata(mockUrl); + + expect(result).toEqual({ + description: undefined, + favicon: undefined, + frame: undefined, + html: undefined, + image: undefined, + lastIndexedAt: expect.any(String), + nft: undefined, + site: undefined, + title: undefined, + url: mockUrl + }); + }); +}); diff --git a/apps/api/src/helpers/oembed/getMetadata.ts b/apps/api/src/helpers/oembed/getMetadata.ts new file mode 100644 index 000000000000..c7d88436e73f --- /dev/null +++ b/apps/api/src/helpers/oembed/getMetadata.ts @@ -0,0 +1,44 @@ +import getFavicon from "@hey/helpers/getFavicon"; +import type { OG } from "@hey/types/misc"; +import axios from "axios"; +import { parseHTML } from "linkedom"; +import { HEY_USER_AGENT } from "../constants"; +import getProxyUrl from "./getProxyUrl"; +import generateIframe from "./meta/generateIframe"; +import getDescription from "./meta/getDescription"; +import getEmbedUrl from "./meta/getEmbedUrl"; +import getFrame from "./meta/getFrame"; +import getImage from "./meta/getImage"; +import getNft from "./meta/getNft"; +import getSite from "./meta/getSite"; +import getTitle from "./meta/getTitle"; + +const getMetadata = async (url: string): Promise => { + try { + const { data } = await axios.get(url, { + headers: { "User-Agent": HEY_USER_AGENT } + }); + + const { document } = parseHTML(data); + const image = getImage(document) as string; + + const metadata: OG = { + description: getDescription(document), + favicon: getFavicon(url), + frame: getFrame(document, url), + html: generateIframe(getEmbedUrl(document), url), + image: getProxyUrl(image), + lastIndexedAt: new Date().toISOString(), + nft: getNft(document, url), + site: getSite(document), + title: getTitle(document), + url + }; + + return metadata; + } catch { + return null; + } +}; + +export default getMetadata; diff --git a/apps/api/src/helpers/oembed/getProxyUrl.spec.ts b/apps/api/src/helpers/oembed/getProxyUrl.spec.ts new file mode 100644 index 000000000000..1da3991f5368 --- /dev/null +++ b/apps/api/src/helpers/oembed/getProxyUrl.spec.ts @@ -0,0 +1,47 @@ +import { HEY_IMAGEKIT_URL } from "@hey/data/constants"; +import { describe, expect, test } from "vitest"; +import getProxyUrl from "./getProxyUrl"; + +describe("getProxyUrl", () => { + const mockImageKitUrl = HEY_IMAGEKIT_URL; + + test("should return the original URL if it's a direct URL (Zora)", () => { + const url = "https://zora.co/api/thumbnail/example.jpg"; + const result = getProxyUrl(url); + expect(result).toBe(url); + }); + + test("should return the original URL if it's a direct URL (Lu.ma)", () => { + const url = "https://social-images.lu.ma/example.jpg"; + const result = getProxyUrl(url); + expect(result).toBe(url); + }); + + test("should return the original URL if it's a direct URL (Drips)", () => { + const url = "https://drips.network/example.jpg"; + const result = getProxyUrl(url); + expect(result).toBe(url); + }); + + test("should return a proxied URL if it's not a direct URL", () => { + const url = "https://example.com/image.jpg"; + const result = getProxyUrl(url); + expect(result).toBe( + `${mockImageKitUrl}/oembed/tr:di-placeholder.webp,h-400,w-400/${url}` + ); + }); + + test("should return null if the URL is empty", () => { + const url = ""; + const result = getProxyUrl(url); + expect(result).toBeNull(); + }); + + test("should return a proxied URL if the URL is from a different domain", () => { + const url = "https://randomwebsite.com/image.jpg"; + const result = getProxyUrl(url); + expect(result).toBe( + `${mockImageKitUrl}/oembed/tr:di-placeholder.webp,h-400,w-400/${url}` + ); + }); +}); diff --git a/apps/api/src/helpers/oembed/getProxyUrl.ts b/apps/api/src/helpers/oembed/getProxyUrl.ts new file mode 100644 index 000000000000..9ef8e4ab23ed --- /dev/null +++ b/apps/api/src/helpers/oembed/getProxyUrl.ts @@ -0,0 +1,23 @@ +import { HEY_IMAGEKIT_URL } from "@hey/data/constants"; + +const directUrls = [ + "zora.co/api/thumbnail", // Zora + "social-images.lu.ma", // Lu.ma + "drips.network" // Drips +]; + +const getProxyUrl = (url: string) => { + if (!url) { + return null; + } + + const isDirect = directUrls.some((directUrl) => url.includes(directUrl)); + + if (isDirect) { + return url; + } + + return `${HEY_IMAGEKIT_URL}/oembed/tr:di-placeholder.webp,h-400,w-400/${url}`; +}; + +export default getProxyUrl; diff --git a/apps/api/src/helpers/oembed/meta/generateIframe.spec.ts b/apps/api/src/helpers/oembed/meta/generateIframe.spec.ts new file mode 100644 index 000000000000..65d8fbd2cab2 --- /dev/null +++ b/apps/api/src/helpers/oembed/meta/generateIframe.spec.ts @@ -0,0 +1,134 @@ +import { describe, expect, test, vi } from "vitest"; +import generateIframe from "./generateIframe"; + +vi.mock("@hey/data/og", () => ({ + ALLOWED_HTML_HOSTS: [ + "youtube.com", + "youtu.be", + "tape.xyz", + "twitch.tv", + "kick.com", + "open.spotify.com", + "soundcloud.com", + "oohlala.xyz", + "suno.com" + ] +})); + +describe("generateIframe", () => { + const universalSize = `width="100%" height="415"`; + + test("should generate iframe for a YouTube URL", () => { + const url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"; + const embedUrl = "https://www.youtube.com/embed/dQw4w9WgXcQ"; + const result = generateIframe(embedUrl, url); + + expect(result).toBe( + `` + ); + }); + + test("should generate iframe for a short YouTube URL", () => { + const url = "https://youtu.be/dQw4w9WgXcQ"; + const embedUrl = "https://www.youtube.com/embed/dQw4w9WgXcQ"; + const result = generateIframe(embedUrl, url); + + expect(result).toBe( + `` + ); + }); + + test("should generate iframe for a Twitch video URL", () => { + const url = "https://www.twitch.tv/videos/123456789"; + const embedUrl = "https://www.twitch.tv/videos/123456789"; + const result = generateIframe(embedUrl, url); + + expect(result).toBe( + `` + ); + }); + + test("should generate iframe for a Spotify track URL", () => { + const url = "https://open.spotify.com/track/abc123"; + const embedUrl = "https://open.spotify.com/track/abc123"; + const result = generateIframe(embedUrl, url); + + expect(result).toBe( + `` + ); + }); + + test("should generate iframe for a Spotify playlist URL", () => { + const url = "https://open.spotify.com/playlist/def456"; + const embedUrl = "https://open.spotify.com/playlist/def456"; + const result = generateIframe(embedUrl, url); + + expect(result).toBe( + `` + ); + }); + + test("should return null for unsupported hosts", () => { + const url = "https://unsupported.com/video/12345"; + const embedUrl = "https://unsupported.com/video/12345"; + const result = generateIframe(embedUrl, url); + + expect(result).toBeNull(); + }); + + test("should return null if no embed URL is provided", () => { + const url = "https://www.youtube.com/watch?v=dQw4w9WgXcQ"; + const result = generateIframe(null, url); + + expect(result).toBeNull(); + }); + + test("should generate iframe for a SoundCloud URL", () => { + const url = "https://soundcloud.com/artist/track"; + const embedUrl = "https://soundcloud.com/artist/track"; + const result = generateIframe(embedUrl, url); + + expect(result).toBe(``); + }); + + test("should generate iframe for an Oohlala URL", () => { + const url = "https://oohlala.xyz/playlist/123456789abcdef"; + const embedUrl = "https://oohlala.xyz/playlist/123456789abcdef"; + const result = generateIframe(embedUrl, url); + + expect(result).toBe(``); + }); + + test("should generate iframe for a Kick URL", () => { + const url = "https://kick.com/streamer"; + const embedUrl = "https://kick.com/streamer"; + const result = generateIframe(embedUrl, url); + + expect(result).toBe( + `` + ); + }); + + test("should generate iframe for a Suno URL", () => { + const url = "https://suno.com/song/81989195-e10e-42b4-abe8-eacf24ee2b6d"; + const embedUrl = + "https://suno.com/embed/81989195-e10e-42b4-abe8-eacf24ee2b6d"; + const result = generateIframe(embedUrl, url); + + expect(result).toBe( + `` + ); + }); +}); diff --git a/apps/api/src/helpers/oembed/meta/generateIframe.ts b/apps/api/src/helpers/oembed/meta/generateIframe.ts new file mode 100644 index 000000000000..b8deb1130254 --- /dev/null +++ b/apps/api/src/helpers/oembed/meta/generateIframe.ts @@ -0,0 +1,118 @@ +import { ALLOWED_HTML_HOSTS } from "@hey/data/og"; + +// URLs that are manually picked to be embedded that dont have embed metatags +const pickUrlSites = ["open.spotify.com", "kick.com", "suno.com"]; + +// URLs that should not have query params removed +const skipClean = ["youtube.com", "youtu.be"]; + +const spotifyTrackUrlRegex = + /^ht{2}ps?:\/{2}open\.spotify\.com\/track\/[\dA-Za-z]+(\?si=[\dA-Za-z]+)?$/; +const spotifyPlaylistUrlRegex = + /^ht{2}ps?:\/{2}open\.spotify\.com\/playlist\/[\dA-Za-z]+(\?si=[\dA-Za-z]+)?$/; +const oohlalaUrlRegex = + /^ht{2}ps?:\/{2}oohlala\.xyz\/playlist\/[\dA-Fa-f-]+(\?si=[\dA-Za-z]+)?$/; +const soundCloudRegex = + /^ht{2}ps?:\/{2}soundcloud\.com(?:\/[\dA-Za-z-]+){2}(\?si=[\dA-Za-z]+)?$/; +const youtubeRegex = + /^https?:\/\/(?:www\.)?youtu(?:be\.com\/watch\?v=|\.be\/)([\w-]+)(?:\?.*)?$/; +const tapeRegex = + /^https?:\/\/tape\.xyz\/watch\/[\dA-Za-z-]+(\?si=[\dA-Za-z]+)?$/; +const twitchRegex = /^https?:\/\/www\.twitch\.tv\/videos\/[\dA-Za-z-]+$/; +const kickRegex = /^https?:\/\/kick\.com\/[\dA-Za-z-]+$/; +const sunoRegex = + /^https?:\/\/suno\.com\/song\/[\dA-Fa-f-]+(\?si=[\dA-Za-z]+)?$/; + +const generateIframe = ( + embedUrl: null | string, + url: string +): null | string => { + const universalSize = `width="100%" height="415"`; + const parsedUrl = new URL(url); + const hostname = parsedUrl.hostname.replace("www.", ""); + const pickedUrl = pickUrlSites.includes(hostname) ? url : embedUrl; + // Remove query params from url + const cleanedUrl = skipClean ? url : (pickedUrl?.split("?")[0] as string); + + if (!ALLOWED_HTML_HOSTS.includes(hostname) || !pickedUrl) { + return null; + } + + switch (hostname) { + case "youtube.com": + case "youtu.be": { + if (youtubeRegex.test(cleanedUrl)) { + return ``; + } + + return null; + } + case "tape.xyz": { + if (tapeRegex.test(cleanedUrl)) { + return ``; + } + + return null; + } + case "twitch.tv": { + const twitchEmbedUrl = pickedUrl.replace( + "&player=facebook&autoplay=true&parent=meta.tag", + "&player=hey&autoplay=false&parent=hey.xyz" + ); + if (twitchRegex.test(cleanedUrl)) { + return ``; + } + + return null; + } + case "kick.com": { + const kickEmbedUrl = pickedUrl.replace("kick.com", "player.kick.com"); + if (kickRegex.test(cleanedUrl)) { + return ``; + } + + return null; + } + case "open.spotify.com": { + const spotifySize = `style="max-width: 100%;" width="100%"`; + if (spotifyTrackUrlRegex.test(cleanedUrl)) { + const spotifyUrl = pickedUrl.replace("/track", "/embed/track"); + return ``; + } + + if (spotifyPlaylistUrlRegex.test(cleanedUrl)) { + const spotifyUrl = pickedUrl.replace("/playlist", "/embed/playlist"); + return ``; + } + + return null; + } + case "soundcloud.com": { + if (soundCloudRegex.test(cleanedUrl)) { + return ``; + } + + return null; + } + case "oohlala.xyz": { + if (oohlalaUrlRegex.test(cleanedUrl)) { + return ``; + } + + return null; + } + case "suno.com": { + const sunoSize = `style="max-width: 100%;" width="100%"`; + if (sunoRegex.test(cleanedUrl)) { + const sunoUrl = pickedUrl.replace("/song", "/embed"); + return ``; + } + + return null; + } + default: + return ``; + } +}; + +export default generateIframe; diff --git a/apps/api/src/helpers/oembed/meta/getDescription.spec.ts b/apps/api/src/helpers/oembed/meta/getDescription.spec.ts new file mode 100644 index 000000000000..23724398d9ef --- /dev/null +++ b/apps/api/src/helpers/oembed/meta/getDescription.spec.ts @@ -0,0 +1,73 @@ +import { parseHTML } from "linkedom"; +import { describe, expect, test } from "vitest"; +import getDescription from "./getDescription"; + +describe("getDescription", () => { + test("should return the content of the 'og:description' meta tag", () => { + const html = ` + + + + + + `; + const { document } = parseHTML(html); + const result = getDescription(document); + + expect(result).toBe("This is the OG description"); + }); + + test("should return the content of the 'twitter:description' meta tag if 'og:description' is not present", () => { + const html = ` + + + + + + `; + const { document } = parseHTML(html); + const result = getDescription(document); + + expect(result).toBe("This is the Twitter description"); + }); + + test("should return the content of 'name=og:description' if it's present", () => { + const html = ` + + + + + + `; + const { document } = parseHTML(html); + const result = getDescription(document); + + expect(result).toBe("This is the OG description with name attribute"); + }); + + test("should return null if neither 'og:description' nor 'twitter:description' is present", () => { + const html = ` + + + + `; + const { document } = parseHTML(html); + const result = getDescription(document); + + expect(result).toBeNull(); + }); + + test("should return null if the 'content' attribute is missing in 'og:description'", () => { + const html = ` + + + + + + `; + const { document } = parseHTML(html); + const result = getDescription(document); + + expect(result).toBeNull(); + }); +}); diff --git a/apps/api/src/helpers/oembed/meta/getDescription.ts b/apps/api/src/helpers/oembed/meta/getDescription.ts new file mode 100644 index 000000000000..351bb6c2bb2f --- /dev/null +++ b/apps/api/src/helpers/oembed/meta/getDescription.ts @@ -0,0 +1,20 @@ +const getDescription = (document: Document): null | string => { + const og = + document.querySelector('meta[name="og:description"]') || + document.querySelector('meta[property="og:description"]'); + const twitter = + document.querySelector('meta[name="twitter:description"]') || + document.querySelector('meta[property="twitter:description"]'); + + if (og) { + return og.getAttribute("content"); + } + + if (twitter) { + return twitter.getAttribute("content"); + } + + return null; +}; + +export default getDescription; diff --git a/apps/api/src/helpers/oembed/meta/getEmbedUrl.spec.ts b/apps/api/src/helpers/oembed/meta/getEmbedUrl.spec.ts new file mode 100644 index 000000000000..bce1103f1dc5 --- /dev/null +++ b/apps/api/src/helpers/oembed/meta/getEmbedUrl.spec.ts @@ -0,0 +1,87 @@ +import { parseHTML } from "linkedom"; +import { describe, expect, test } from "vitest"; +import getEmbedUrl from "./getEmbedUrl"; + +describe("getEmbedUrl", () => { + test("should return the content of the 'og:video:url' meta tag", () => { + const html = ` + + + + + + `; + const { document } = parseHTML(html); + const result = getEmbedUrl(document); + + expect(result).toBe("https://example.com/video.mp4"); + }); + + test("should return the content of the 'og:video:secure_url' meta tag", () => { + const html = ` + + + + + + `; + const { document } = parseHTML(html); + const result = getEmbedUrl(document); + + expect(result).toBe("https://secure.example.com/video.mp4"); + }); + + test("should return the content of the 'twitter:player' meta tag if 'og:video' is not present", () => { + const html = ` + + + + + + `; + const { document } = parseHTML(html); + const result = getEmbedUrl(document); + + expect(result).toBe("https://twitter.com/player/video.mp4"); + }); + + test("should return the content of 'name=twitter:player' if it's present", () => { + const html = ` + + + + + + `; + const { document } = parseHTML(html); + const result = getEmbedUrl(document); + + expect(result).toBe("https://twitter.com/player/video.mp4"); + }); + + test("should return null if neither 'og:video' nor 'twitter:player' is present", () => { + const html = ` + + + + `; + const { document } = parseHTML(html); + const result = getEmbedUrl(document); + + expect(result).toBeNull(); + }); + + test("should return null if the 'content' attribute is missing in 'og:video:url'", () => { + const html = ` + + + + + + `; + const { document } = parseHTML(html); + const result = getEmbedUrl(document); + + expect(result).toBeNull(); + }); +}); diff --git a/apps/api/src/helpers/oembed/meta/getEmbedUrl.ts b/apps/api/src/helpers/oembed/meta/getEmbedUrl.ts new file mode 100644 index 000000000000..b4c08edf2ed6 --- /dev/null +++ b/apps/api/src/helpers/oembed/meta/getEmbedUrl.ts @@ -0,0 +1,22 @@ +const getEmbedUrl = (document: Document): null | string => { + const og = + document.querySelector('meta[name="og:video:url"]') || + document.querySelector('meta[name="og:video:secure_url"]') || + document.querySelector('meta[property="og:video:url"]') || + document.querySelector('meta[property="og:video:secure_url"]'); + const twitter = + document.querySelector('meta[name="twitter:player"]') || + document.querySelector('meta[property="twitter:player"]'); + + if (og) { + return og.getAttribute("content"); + } + + if (twitter) { + return twitter.getAttribute("content"); + } + + return null; +}; + +export default getEmbedUrl; diff --git a/apps/api/src/helpers/oembed/meta/getFrame.spec.ts b/apps/api/src/helpers/oembed/meta/getFrame.spec.ts new file mode 100644 index 000000000000..6ed1295fee02 --- /dev/null +++ b/apps/api/src/helpers/oembed/meta/getFrame.spec.ts @@ -0,0 +1,146 @@ +import { parseHTML } from "linkedom"; +import { describe, expect, test } from "vitest"; +import getFrame from "./getFrame"; + +describe("getFrame", () => { + test("should return frame data when required metadata is present", () => { + const html = ` + + + + + + + + + `; + const { document } = parseHTML(html); + const result = getFrame(document, "https://example.com"); + + expect(result).toEqual({ + acceptsAnonymous: false, + acceptsLens: true, + buttons: [], + frameUrl: "https://example.com", + image: "https://example.com/image.jpg", + imageAspectRatio: null, + inputText: null, + lensFramesVersion: "true", + openFramesVersion: "1.0", + postUrl: "https://example.com/post", + state: null + }); + }); + + test("should return frame data with buttons", () => { + const html = ` + + + + + + + + + + + + `; + const { document } = parseHTML(html); + const result = getFrame(document, "https://example.com"); + + expect(result).toEqual({ + acceptsAnonymous: false, + acceptsLens: true, + buttons: [ + { + action: "like", + button: "Like", + postUrl: "https://example.com/post", + target: "https://example.com/like" + } + ], + frameUrl: "https://example.com", + image: "https://example.com/image.jpg", + imageAspectRatio: null, + inputText: null, + lensFramesVersion: "true", + openFramesVersion: "1.0", + postUrl: "https://example.com/post", + state: null + }); + }); + + test("should return null if neither accepts Lens nor anonymous", () => { + const html = ` + + + + + + + `; + const { document } = parseHTML(html); + const result = getFrame(document, "https://example.com"); + + expect(result).toBeNull(); + }); + + test("should handle anonymous frame acceptance", () => { + const html = ` + + + + + + + + `; + const { document } = parseHTML(html); + const result = getFrame(document, "https://example.com"); + + expect(result).toEqual({ + acceptsAnonymous: true, + acceptsLens: false, + buttons: [], + frameUrl: "https://example.com", + image: "https://example.com/image.jpg", + imageAspectRatio: null, + inputText: null, + lensFramesVersion: null, + openFramesVersion: null, + postUrl: "https://example.com/post", + state: null + }); + }); + + test("should return frame with input text and state", () => { + const html = ` + + + + + + + + + + `; + const { document } = parseHTML(html); + const result = getFrame(document, "https://example.com"); + + expect(result).toEqual({ + acceptsAnonymous: false, + acceptsLens: true, + buttons: [], + frameUrl: "https://example.com", + image: "https://example.com/image.jpg", + imageAspectRatio: null, + inputText: "Enter your comment", + lensFramesVersion: "true", + openFramesVersion: null, + postUrl: "https://example.com/post", + state: "ready" + }); + }); +}); diff --git a/apps/api/src/helpers/oembed/meta/getFrame.ts b/apps/api/src/helpers/oembed/meta/getFrame.ts new file mode 100644 index 000000000000..a0a40580e4be --- /dev/null +++ b/apps/api/src/helpers/oembed/meta/getFrame.ts @@ -0,0 +1,67 @@ +import type { ButtonType, Frame } from "@hey/types/misc"; + +const getFrame = (document: Document, url?: string): Frame | null => { + const getMeta = (key: string) => { + const selector = `meta[name="${key}"], meta[property="${key}"]`; + const metaTag = document.querySelector(selector); + return metaTag ? metaTag.getAttribute("content") : null; + }; + + const openFramesVersion = getMeta("of:version"); + const lensFramesVersion = getMeta("of:accepts:lens"); + const acceptsAnonymous = getMeta("of:accepts:anonymous"); + const image = getMeta("of:image") || getMeta("og:image"); + const imageAspectRatio = getMeta("of:image:aspect_ratio"); + const postUrl = getMeta("of:post_url") || url; + const frameUrl = url || ""; + const inputText = getMeta("of:input:text") || getMeta("fc:input:text"); + const state = getMeta("of:state") || getMeta("fc:state"); + + const buttons: Frame["buttons"] = []; + for (let i = 1; i < 5; i++) { + const button = getMeta(`of:button:${i}`) || getMeta(`fc:frame:button:${i}`); + const action = (getMeta(`of:button:${i}:action`) || + getMeta(`fc:frame:button:${i}:action`) || + "post") as ButtonType; + const target = (getMeta(`of:button:${i}:target`) || + getMeta(`fc:frame:button:${i}:target`)) as string; + + // Button post_url -> OpenFrame post_url -> frame url + const buttonPostUrl = + getMeta(`of:button:${i}:post_url`) || + getMeta(`fc:frame:button:${i}:post_url`) || + postUrl; + + if (!button) { + break; + } + + buttons.push({ action, button, postUrl: buttonPostUrl, target }); + } + + // Frames must be OpenFrame with accepted protocol of Lens (account authentication) or anonymous (no authentication) + if (!lensFramesVersion && !acceptsAnonymous) { + return null; + } + + // Frame must contain valid elements + if (!postUrl || !image) { + return null; + } + + return { + acceptsAnonymous: Boolean(acceptsAnonymous), + acceptsLens: Boolean(lensFramesVersion), + buttons, + frameUrl, + image, + imageAspectRatio, + inputText, + lensFramesVersion, + openFramesVersion, + postUrl, + state + }; +}; + +export default getFrame; diff --git a/apps/api/src/helpers/oembed/meta/getImage.spec.ts b/apps/api/src/helpers/oembed/meta/getImage.spec.ts new file mode 100644 index 000000000000..9647c105588e --- /dev/null +++ b/apps/api/src/helpers/oembed/meta/getImage.spec.ts @@ -0,0 +1,87 @@ +import { parseHTML } from "linkedom"; +import { describe, expect, test } from "vitest"; +import getImage from "./getImage"; + +describe("getImage", () => { + test("should return the content of the 'og:image' meta tag", () => { + const html = ` + + + + + + `; + const { document } = parseHTML(html); + const result = getImage(document); + + expect(result).toBe("https://example.com/image.jpg"); + }); + + test("should return the content of the 'twitter:image' meta tag if 'og:image' is not present", () => { + const html = ` + + + + + + `; + const { document } = parseHTML(html); + const result = getImage(document); + + expect(result).toBe("https://twitter.com/image.jpg"); + }); + + test("should return the content of 'twitter:image:src' if both 'og:image' and 'twitter:image' are not present", () => { + const html = ` + + + + + + `; + const { document } = parseHTML(html); + const result = getImage(document); + + expect(result).toBe("https://twitter.com/image-src.jpg"); + }); + + test("should return the content of 'property=twitter:image' if 'name' attributes are not present", () => { + const html = ` + + + + + + `; + const { document } = parseHTML(html); + const result = getImage(document); + + expect(result).toBe("https://twitter.com/image.jpg"); + }); + + test("should return null if neither 'og:image' nor 'twitter:image' meta tags are present", () => { + const html = ` + + + + `; + const { document } = parseHTML(html); + const result = getImage(document); + + expect(result).toBeNull(); + }); + + test("should return null if the 'content' attribute is missing in 'og:image'", () => { + const html = ` + + + + + + `; + const { document } = parseHTML(html); + const result = getImage(document); + + expect(result).toBeNull(); + }); +}); diff --git a/apps/api/src/helpers/oembed/meta/getImage.ts b/apps/api/src/helpers/oembed/meta/getImage.ts new file mode 100644 index 000000000000..7136cd5aae42 --- /dev/null +++ b/apps/api/src/helpers/oembed/meta/getImage.ts @@ -0,0 +1,22 @@ +const getImage = (document: Document): null | string => { + const og = + document.querySelector('meta[name="og:image"]') || + document.querySelector('meta[property="og:image"]'); + const twitter = + document.querySelector('meta[name="twitter:image"]') || + document.querySelector('meta[name="twitter:image:src"]') || + document.querySelector('meta[property="twitter:image"]') || + document.querySelector('meta[property="twitter:image:src"]'); + + if (og) { + return og.getAttribute("content"); + } + + if (twitter) { + return twitter.getAttribute("content"); + } + + return null; +}; + +export default getImage; diff --git a/apps/api/src/helpers/oembed/meta/getNft.spec.ts b/apps/api/src/helpers/oembed/meta/getNft.spec.ts new file mode 100644 index 000000000000..649d29490228 --- /dev/null +++ b/apps/api/src/helpers/oembed/meta/getNft.spec.ts @@ -0,0 +1,103 @@ +import { parseHTML } from "linkedom"; +import { describe, expect, test, vi } from "vitest"; +import getNft from "./getNft"; + +vi.mock("@hey/data/og", () => ({ + IGNORED_NFT_HOSTS: ["example.com"] +})); + +vi.mock("@hey/helpers/getNftChainId", () => ({ + default: vi.fn(() => "ethereum") +})); + +describe("getNft", () => { + test("should return null if the source URL is from an ignored host", () => { + const html = ` + + + + + + `; + const { document } = parseHTML(html); + const result = getNft(document, "https://example.com/nft/123"); + + expect(result).toBeNull(); + }); + + test("should return NFT data when all metadata is present", () => { + const html = ` + + + + + + + + + + + + + `; + const { document } = parseHTML(html); + const result = getNft(document, "https://nftsite.com/nft/123"); + + expect(result).toEqual({ + chain: "ethereum", + collectionName: "Test Collection", + contractAddress: "0x1234567890abcdef", + creatorAddress: "0xabcdef1234567890", + description: "Test NFT Description", + endTime: null, + mediaUrl: "https://example.com/nft.jpg", + mintCount: "100", + mintStatus: null, + mintUrl: null, + schema: "ERC721", + sourceUrl: "https://nftsite.com/nft/123" + }); + }); + + test("should return data from Frame buttons if present", () => { + const html = ` + + + + + + + + + `; + const { document } = parseHTML(html); + const result = getNft(document, "https://nftsite.com/nft/123"); + + expect(result).toEqual({ + chain: "ethereum", + collectionName: "Frame Collection", + contractAddress: null, + creatorAddress: null, + description: null, + endTime: null, + mediaUrl: "https://frame.com/image.jpg", + mintCount: null, + mintStatus: null, + mintUrl: null, + schema: null, + sourceUrl: "https://nftsite.com/nft/123" + }); + }); + + test("should return null if collectionName, contractAddress, creatorAddress, and schema are all missing", () => { + const html = ` + + + + `; + const { document } = parseHTML(html); + const result = getNft(document, "https://nftsite.com/nft/123"); + + expect(result).toBeNull(); + }); +}); diff --git a/apps/api/src/helpers/oembed/meta/getNft.ts b/apps/api/src/helpers/oembed/meta/getNft.ts new file mode 100644 index 000000000000..8a202abca1cd --- /dev/null +++ b/apps/api/src/helpers/oembed/meta/getNft.ts @@ -0,0 +1,69 @@ +import { IGNORED_NFT_HOSTS } from "@hey/data/og"; +import getNftChainId from "@hey/helpers/getNftChainId"; +import type { Nft } from "@hey/types/misc"; +import type { Address } from "viem"; + +// https://reflect.site/g/yoginth/hey-nft-extended-open-graph-spec/780502f3c8a3404bb2d7c39ec091602e +const getNft = (document: Document, sourceUrl: string): Nft | null => { + if (IGNORED_NFT_HOSTS.includes(new URL(sourceUrl).hostname)) { + return null; + } + + const getMeta = (key: string) => { + const selector = `meta[name="${key}"], meta[property="${key}"]`; + const metaTag = document.querySelector(selector); + return metaTag ? metaTag.getAttribute("content") : null; + }; + + let collectionName = getMeta("eth:nft:collection") as string; + const contractAddress = getMeta("eth:nft:contract_address") as Address; + const creatorAddress = getMeta("eth:nft:creator_address") as Address; + let chain = getMeta("eth:nft:chain") || getMeta("nft:chain"); + let mediaUrl = (getMeta("og:image") || + getMeta("eth:nft:media_url")) as string; + const description = getMeta("og:description") as string; + const mintCount = getMeta("eth:nft:mint_count") as string; + const mintStatus = getMeta("eth:nft:status"); + const mintUrl = getMeta("eth:nft:mint_url") as string; + const schema = getMeta("eth:nft:schema") as string; + const endTime = getMeta("eth:nft:endtime"); + + if (!collectionName || !mediaUrl) { + const hasFCFrame = getMeta("fc:frame:button:1:action") === "mint"; + + if (hasFCFrame) { + const target = getMeta("fc:frame:button:1:target"); + collectionName = getMeta("og:title") as string; + + chain = target?.startsWith("eip") + ? getNftChainId(target.split(":")[1]) + : null; + mediaUrl = (getMeta("fc:frame:image") || getMeta("og:image")) as string; + + if (!collectionName || !mediaUrl) { + return null; + } + } + } + + if (!collectionName && !contractAddress && !creatorAddress && !schema) { + return null; + } + + return { + chain, + collectionName, + contractAddress, + creatorAddress, + description, + endTime, + mediaUrl, + mintCount, + mintStatus, + mintUrl, + schema, + sourceUrl + }; +}; + +export default getNft; diff --git a/apps/api/src/helpers/oembed/meta/getSite.spec.ts b/apps/api/src/helpers/oembed/meta/getSite.spec.ts new file mode 100644 index 000000000000..dc93523b16e4 --- /dev/null +++ b/apps/api/src/helpers/oembed/meta/getSite.spec.ts @@ -0,0 +1,87 @@ +import { parseHTML } from "linkedom"; +import { describe, expect, test } from "vitest"; +import getSite from "./getSite"; + +describe("getSite", () => { + test("should return the content of the 'og:site_name' meta tag", () => { + const html = ` + + + + + + `; + const { document } = parseHTML(html); + const result = getSite(document); + + expect(result).toBe("Example Site"); + }); + + test("should return the content of the 'twitter:site' meta tag if 'og:site_name' is not present", () => { + const html = ` + + + + + + `; + const { document } = parseHTML(html); + const result = getSite(document); + + expect(result).toBe("@example"); + }); + + test("should return the content of 'property=og:site_name' if 'name=og:site_name' is not present", () => { + const html = ` + + + + + + `; + const { document } = parseHTML(html); + const result = getSite(document); + + expect(result).toBe("Another Example Site"); + }); + + test("should return the content of 'property=twitter:site' if 'name=twitter:site' is not present", () => { + const html = ` + + + + + + `; + const { document } = parseHTML(html); + const result = getSite(document); + + expect(result).toBe("@anotherexample"); + }); + + test("should return null if neither 'og:site_name' nor 'twitter:site' meta tags are present", () => { + const html = ` + + + + `; + const { document } = parseHTML(html); + const result = getSite(document); + + expect(result).toBeNull(); + }); + + test("should return null if the 'content' attribute is missing in 'og:site_name'", () => { + const html = ` + + + + + + `; + const { document } = parseHTML(html); + const result = getSite(document); + + expect(result).toBeNull(); + }); +}); diff --git a/apps/api/src/helpers/oembed/meta/getSite.ts b/apps/api/src/helpers/oembed/meta/getSite.ts new file mode 100644 index 000000000000..075d09a542fc --- /dev/null +++ b/apps/api/src/helpers/oembed/meta/getSite.ts @@ -0,0 +1,20 @@ +const getSite = (document: Document): null | string => { + const og = + document.querySelector('meta[name="og:site_name"]') || + document.querySelector('meta[property="og:site_name"]'); + const twitter = + document.querySelector('meta[name="twitter:site"]') || + document.querySelector('meta[property="twitter:site"]'); + + if (og) { + return og.getAttribute("content"); + } + + if (twitter) { + return twitter.getAttribute("content"); + } + + return null; +}; + +export default getSite; diff --git a/apps/api/src/helpers/oembed/meta/getTitle.spec.ts b/apps/api/src/helpers/oembed/meta/getTitle.spec.ts new file mode 100644 index 000000000000..7e69ab02b8be --- /dev/null +++ b/apps/api/src/helpers/oembed/meta/getTitle.spec.ts @@ -0,0 +1,87 @@ +import { parseHTML } from "linkedom"; +import { describe, expect, test } from "vitest"; +import getTitle from "./getTitle"; + +describe("getTitle", () => { + test("should return the content of the 'og:title' meta tag", () => { + const html = ` + + + + + + `; + const { document } = parseHTML(html); + const result = getTitle(document); + + expect(result).toBe("Example OG Title"); + }); + + test("should return the content of the 'twitter:title' meta tag if 'og:title' is not present", () => { + const html = ` + + + + + + `; + const { document } = parseHTML(html); + const result = getTitle(document); + + expect(result).toBe("Example Twitter Title"); + }); + + test("should return the content of 'property=og:title' if 'name=og:title' is not present", () => { + const html = ` + + + + + + `; + const { document } = parseHTML(html); + const result = getTitle(document); + + expect(result).toBe("OG Property Title"); + }); + + test("should return the content of 'property=twitter:title' if 'name=twitter:title' is not present", () => { + const html = ` + + + + + + `; + const { document } = parseHTML(html); + const result = getTitle(document); + + expect(result).toBe("Twitter Property Title"); + }); + + test("should return null if neither 'og:title' nor 'twitter:title' meta tags are present", () => { + const html = ` + + + + `; + const { document } = parseHTML(html); + const result = getTitle(document); + + expect(result).toBeNull(); + }); + + test("should return null if the 'content' attribute is missing in 'og:title'", () => { + const html = ` + + + + + + `; + const { document } = parseHTML(html); + const result = getTitle(document); + + expect(result).toBeNull(); + }); +}); diff --git a/apps/api/src/helpers/oembed/meta/getTitle.ts b/apps/api/src/helpers/oembed/meta/getTitle.ts new file mode 100644 index 000000000000..0b434c077101 --- /dev/null +++ b/apps/api/src/helpers/oembed/meta/getTitle.ts @@ -0,0 +1,20 @@ +const getTitle = (document: Document): null | string => { + const og = + document.querySelector('meta[name="og:title"]') || + document.querySelector('meta[property="og:title"]'); + const twitter = + document.querySelector('meta[name="twitter:title"]') || + document.querySelector('meta[property="twitter:title"]'); + + if (og) { + return og.getAttribute("content"); + } + + if (twitter) { + return twitter.getAttribute("content"); + } + + return null; +}; + +export default getTitle; diff --git a/apps/api/src/helpers/responses.ts b/apps/api/src/helpers/responses.ts new file mode 100644 index 000000000000..1b5fa62aa2db --- /dev/null +++ b/apps/api/src/helpers/responses.ts @@ -0,0 +1,16 @@ +import { Errors } from "@hey/data/errors"; +import type { Response } from "express"; + +export const invalidBody = (response: Response) => { + return response + .status(400) + .json({ error: Errors.InvalidBody, success: false }); +}; + +export const noBody = (response: Response) => { + return response.status(400).json({ error: Errors.NoBody, success: false }); +}; + +export const notFound = (response: Response) => { + return response.status(404).json({ error: Errors.NotFound, success: false }); +}; diff --git a/apps/api/src/helpers/sitemap/buildSitemap.spec.ts b/apps/api/src/helpers/sitemap/buildSitemap.spec.ts new file mode 100644 index 000000000000..1d52e3ba6ac9 --- /dev/null +++ b/apps/api/src/helpers/sitemap/buildSitemap.spec.ts @@ -0,0 +1,55 @@ +import { describe, expect, test } from "vitest"; +import { buildSitemapXml, buildUrlsetXml } from "./buildSitemap"; + +describe("buildUrlsetXml", () => { + test("should generate correct XML for urlset with loc and lastmod", () => { + const urlset = [ + { loc: "https://example.com/page1", lastmod: "2024-09-30" }, + { loc: "https://example.com/page2" } + ]; + const result = buildUrlsetXml(urlset); + const expected = ` + + + https://example.com/page1 + 2024-09-30 + + + https://example.com/page2 + +`.trim(); + expect(result.trim()).toBe(expected); + }); + + test("should generate correct XML for urlset with loc only", () => { + const urlset = [{ loc: "https://example.com/page1" }]; + const result = buildUrlsetXml(urlset); + const expected = ` + + + https://example.com/page1 + +`.trim(); + expect(result.trim()).toBe(expected); + }); +}); + +describe("buildSitemapXml", () => { + test("should generate correct XML for sitemapindex", () => { + const sitemap = [ + { loc: "https://example.com/sitemap1.xml" }, + { loc: "https://example.com/sitemap2.xml" } + ]; + const result = buildSitemapXml(sitemap); + const expected = ` + + + https://example.com/sitemap1.xml + + + https://example.com/sitemap2.xml + +`.trim(); + expect(result.trim()).toBe(expected); + }); +}); diff --git a/apps/api/src/helpers/sitemap/buildSitemap.ts b/apps/api/src/helpers/sitemap/buildSitemap.ts new file mode 100644 index 000000000000..c08aa13def59 --- /dev/null +++ b/apps/api/src/helpers/sitemap/buildSitemap.ts @@ -0,0 +1,40 @@ +import { XMLBuilder } from "fast-xml-parser"; + +const builder = new XMLBuilder({ + format: true, + ignoreAttributes: false, + processEntities: true, + suppressEmptyNode: true +}); + +interface Urlset { + lastmod?: string; + loc: string; +} + +export const buildUrlsetXml = (url: Urlset[]): string => { + return builder.build({ + urlset: { + "@_xmlns": "http://www.sitemaps.org/schemas/sitemap/0.9", + "@_xmlns:image": "http://www.google.com/schemas/sitemap-image/1.1", + "@_xmlns:mobile": "http://www.google.com/schemas/sitemap-mobile/1.0", + "@_xmlns:news": "http://www.google.com/schemas/sitemap-news/0.9", + "@_xmlns:video": "http://www.google.com/schemas/sitemap-video/1.1", + "@_xmlns:xhtml": "http://www.w3.org/1999/xhtml", + url + } + }); +}; + +interface Sitemap { + loc: string; +} + +export const buildSitemapXml = (sitemap: Sitemap[]): string => { + return builder.build({ + sitemapindex: { + "@_xmlns": "http://www.sitemaps.org/schemas/sitemap/0.9", + sitemap + } + }); +}; diff --git a/apps/api/src/helpers/slack.ts b/apps/api/src/helpers/slack.ts new file mode 100644 index 000000000000..6684c9ca322c --- /dev/null +++ b/apps/api/src/helpers/slack.ts @@ -0,0 +1,40 @@ +import { BRAND_COLOR } from "@hey/data/constants"; +import logger from "@hey/helpers/logger"; +import axios from "axios"; + +const SLACK_CHANNELS = [ + { channel: "#signups", id: "B074BSCRYBY/oje0JD1ymgzB6ZTMNe0zBvRM" }, + { channel: "#events", id: "B074JCLS16Z/F5Lst6mYkthtDwBeCehwGGFR" }, + { channel: "#permissions", id: "B07SJR9RDQW/8ArvhaG4dYffWij0gi5iZY7g" } +]; + +const getChannelId = (channel: string) => { + return SLACK_CHANNELS.find((c) => c.channel === channel)?.id; +}; + +const sendSlackMessage = async ({ + channel, + color = BRAND_COLOR, + fields, + text +}: { + channel: string; + color?: string; + fields?: { + short: boolean; + title: string; + value: string; + }[]; + text: string; +}): Promise => { + if (!process.env.SLACK_WEBHOOK_URL) { + return logger.error("Slack webhook URL not set"); + } + + return await axios.post( + `${process.env.SLACK_WEBHOOK_URL}/${getChannelId(channel)}`, + { channel, color, fallback: text, fields, pretext: text } + ); +}; + +export default sendSlackMessage; diff --git a/apps/api/src/helpers/webhooks/signup/sendSignupNotificationToSlack.ts b/apps/api/src/helpers/webhooks/signup/sendSignupNotificationToSlack.ts new file mode 100644 index 000000000000..42bc8f462e07 --- /dev/null +++ b/apps/api/src/helpers/webhooks/signup/sendSignupNotificationToSlack.ts @@ -0,0 +1,104 @@ +import { POLYGONSCAN_URL } from "@hey/data/constants"; +import logger from "@hey/helpers/logger"; +import type { Address, PublicClient } from "viem"; +import { createPublicClient, decodeEventLog, parseAbi } from "viem"; +import { polygon } from "viem/chains"; +import getRpc from "../../getRpc"; +import sendSlackMessage from "../../slack"; + +const MAX_RETRIES = 10; +const RETRY_DELAY_MS = 3000; +const TOPIC = + "0x30a132e912787e50de6193fe56a96ea6188c0bbf676679d630a25d3293c3e19a"; + +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +const fetchTransactionReceiptWithRetry = async ( + client: PublicClient, + hash: Address, + retries: number = MAX_RETRIES +): Promise => { + for (let attempt = 1; attempt <= retries; attempt++) { + try { + return await client.getTransactionReceipt({ hash }); + } catch { + if (attempt < retries) { + logger.error( + `sendSignupNotificationToSlack: Attempt ${attempt} failed. Retrying in ${RETRY_DELAY_MS / 1000} seconds...` + ); + await sleep(RETRY_DELAY_MS); + } else { + throw new Error( + `sendSignupNotificationToSlack: Failed after ${retries} attempts` + ); + } + } + } +}; + +const sendSignupNotificationToSlack = async (hash: Address) => { + if (!hash) { + return; + } + + logger.info( + `sendSignupNotificationToSlack: Fetching transaction receipt for ${hash}` + ); + + try { + const client = createPublicClient({ + chain: polygon, + transport: getRpc({ mainnet: true }) + }); + + const receipt = await fetchTransactionReceiptWithRetry(client, hash); + const log = receipt.logs.find((log: any) => log.topics[0] === TOPIC); + const data = log?.data; + const decodedData = decodeEventLog({ + abi: parseAbi([ + "event HandleMinted(string handle, string namespace, uint256 handleId, address to, uint256 timestamp)" + ]), + data, + topics: [TOPIC] + }); + + const handle = decodedData.args?.handle; + + if (!handle) { + return; + } + + logger.info( + "sendSignupNotificationToSlack: Sending signup invoice to Slack" + ); + + await sendSlackMessage({ + channel: "#signups", + color: "#22c55e", + fields: [ + { + short: false, + title: "Transaction", + value: `${POLYGONSCAN_URL}/tx/${hash}` + }, + { + short: false, + title: "Account", + value: `https://hey.xyz/u/${handle}` + } + ], + text: ":tada: A new account has been signed up to :hey:" + }); + + logger.info( + `sendSignupNotificationToSlack: Signup Invoice for @${handle} sent to Slack` + ); + } catch (error) { + logger.error( + "sendSignupNotificationToSlack: Failed to send signup notification to Slack", + error as Error + ); + } +}; + +export default sendSignupNotificationToSlack; diff --git a/apps/api/src/lib/api/getProfileMeta.ts b/apps/api/src/lib/api/getProfileMeta.ts deleted file mode 100644 index f672391cb1c8..000000000000 --- a/apps/api/src/lib/api/getProfileMeta.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { gql } from '@apollo/client'; -import generateMeta from '@lib/generateMeta'; -import getIPFSLink from '@lib/getIPFSLink'; -import type { MediaSet, NftImage, Profile } from 'lens'; -import type { NextApiRequest, NextApiResponse } from 'next'; -import client from 'src/apollo'; - -const PROFILE_QUERY = gql` - query Profile($request: SingleProfileQueryRequest!) { - profile(request: $request) { - handle - name - bio - stats { - totalFollowers - totalFollowing - } - picture { - ... on MediaSet { - original { - url - } - } - ... on NftImage { - uri - } - } - } - } -`; - -const getProfileMeta = async (req: NextApiRequest, res: NextApiResponse, handle: string) => { - try { - const { data } = await client.query({ - query: PROFILE_QUERY, - variables: { request: { handle } } - }); - - if (data?.profile) { - const profile: Profile & { picture: MediaSet & NftImage } = data?.profile; - const title = profile?.name - ? `${profile?.name} (@${profile?.handle}) • Lenster` - : `@${profile?.handle} • Lenster`; - const description = profile?.bio ?? ''; - const image = profile - ? `https://ik.imagekit.io/lensterimg/tr:n-avatar/${getIPFSLink( - profile?.picture?.original?.url ?? - profile?.picture?.uri ?? - `https://avatar.tobi.sh/${profile?.ownedBy}_${profile?.handle}.png` - )}` - : 'https://assets.lenster.xyz/images/og/logo.jpeg'; - - return res - .setHeader('Content-Type', 'text/html') - .setHeader('Cache-Control', 's-maxage=86400') - .send(generateMeta(title, description, image)); - } - } catch { - return res - .setHeader('Content-Type', 'text/html') - .setHeader('Cache-Control', 's-maxage=86400') - .send(generateMeta()); - } -}; - -export default getProfileMeta; diff --git a/apps/api/src/lib/api/getPublicationMeta.ts b/apps/api/src/lib/api/getPublicationMeta.ts deleted file mode 100644 index a727dd48b802..000000000000 --- a/apps/api/src/lib/api/getPublicationMeta.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { gql } from '@apollo/client'; -import generateMeta from '@lib/generateMeta'; -import getIPFSLink from '@lib/getIPFSLink'; -import type { Publication } from 'lens'; -import type { NextApiRequest, NextApiResponse } from 'next'; -import client from 'src/apollo'; - -const PUBLICATION_QUERY = gql` - query Post($request: PublicationQueryRequest!) { - publication(request: $request) { - ... on Post { - metadata { - content - } - profile { - handle - ownedBy - picture { - ... on NftImage { - uri - } - ... on MediaSet { - original { - url - } - } - } - } - } - ... on Comment { - metadata { - content - } - profile { - handle - ownedBy - picture { - ... on NftImage { - uri - } - ... on MediaSet { - original { - url - } - } - } - } - } - ... on Mirror { - metadata { - content - } - mirrorOf { - ... on Post { - profile { - handle - ownedBy - picture { - ... on NftImage { - uri - } - ... on MediaSet { - original { - url - } - } - } - } - } - ... on Comment { - profile { - handle - ownedBy - picture { - ... on NftImage { - uri - } - ... on MediaSet { - original { - url - } - } - } - } - } - } - } - } - } -`; - -const getPublicationMeta = async (req: NextApiRequest, res: NextApiResponse, id: string) => { - try { - const { data } = await client.query({ - query: PUBLICATION_QUERY, - variables: { request: { publicationId: id } } - }); - - if (data?.publication) { - const publication: Publication = data?.publication; - const profile: any = - publication?.__typename === 'Mirror' ? publication?.mirrorOf?.profile : publication?.profile; - - const title = `${publication?.__typename === 'Post' ? 'Post' : 'Comment'} by @${ - profile.handle - } • Lenster`; - const description = publication.metadata?.content ?? ''; - const image = profile - ? `https://ik.imagekit.io/lensterimg/tr:n-avatar/${getIPFSLink( - profile?.picture?.original?.url ?? - profile?.picture?.uri ?? - `https://avatar.tobi.sh/${profile?.ownedBy}_${profile?.handle}.png` - )}` - : 'https://assets.lenster.xyz/images/og/logo.jpeg'; - - return res - .setHeader('Content-Type', 'text/html') - .setHeader('Cache-Control', 's-maxage=86400') - .send(generateMeta(title, description, image)); - } - } catch { - return res - .setHeader('Content-Type', 'text/html') - .setHeader('Cache-Control', 's-maxage=86400') - .send(generateMeta()); - } -}; - -export default getPublicationMeta; diff --git a/apps/api/src/lib/generateMeta.ts b/apps/api/src/lib/generateMeta.ts deleted file mode 100644 index 2413921b0b40..000000000000 --- a/apps/api/src/lib/generateMeta.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { APP_NAME, DEFAULT_OG, DESCRIPTION } from 'data/constants'; - -const generateMeta = (title = APP_NAME, description = DESCRIPTION, image = DEFAULT_OG): string => { - return ` - - - ${title} - - - - - - - - - - - - - - - - - - - -

${title}

- - `; -}; - -export default generateMeta; diff --git a/apps/api/src/lib/getIPFSLink.ts b/apps/api/src/lib/getIPFSLink.ts deleted file mode 100644 index f475acf81708..000000000000 --- a/apps/api/src/lib/getIPFSLink.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { IPFS_GATEWAY } from 'data/constants'; - -/** - * - * @param hash - IPFS hash - * @returns IPFS link - */ -const getIPFSLink = (hash: string): string => { - if (!hash) { - return ''; - } - const gateway = IPFS_GATEWAY; - - return hash - .replace(/^Qm[1-9A-Za-z]{44}/gm, `${gateway}${hash}`) - .replace('https://ipfs.io/ipfs/', gateway) - .replace('ipfs://', gateway); -}; - -export default getIPFSLink; diff --git a/apps/api/src/pages/api/analytics/publication.ts b/apps/api/src/pages/api/analytics/publication.ts deleted file mode 100644 index c7cdc7ca6fc7..000000000000 --- a/apps/api/src/pages/api/analytics/publication.ts +++ /dev/null @@ -1,54 +0,0 @@ -import axios from 'axios'; -import { ERROR_MESSAGE } from 'data/constants'; -import type { NextApiRequest, NextApiResponse } from 'next'; -import { DATADOG_APPLICATION_KEY, DATADOG_TOKEN } from 'src/constants'; - -const handler = async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method !== 'GET') { - return res.status(405).json({ success: false, message: 'Invalid method!' }); - } - - const { id } = req.query; - - if (!id) { - return res.status(400).json({ success: false, message: 'Bad request!' }); - } - - try { - const response = await axios('https://api.datadoghq.eu/api/v2/logs/analytics/aggregate', { - method: 'POST', - headers: { - 'dd-api-key': DATADOG_TOKEN, - 'dd-application-key': DATADOG_APPLICATION_KEY - }, - data: { - compute: [{ aggregation: 'count', metric: 'count', type: 'total' }], - filter: { - from: 'now-15d', - to: 'now', - indexes: ['main'], - query: `@props.path:"Publication page" @props.id:"${id}"` - } - } - }); - - const { data } = response?.data; - - return res - .setHeader('Cache-Control', 's-maxage=900') - .status(200) - .json({ - success: true, - response: { - views: data?.buckets[0]?.computes?.c0 - } - }); - } catch (error: any) { - if (error.response.status === 429) { - return res.status(429).json({ success: false, message: 'Rate limited' }); - } - return res.status(500).json({ success: false, message: ERROR_MESSAGE }); - } -}; - -export default handler; diff --git a/apps/api/src/pages/api/index.ts b/apps/api/src/pages/api/index.ts deleted file mode 100644 index 10556bdb5836..000000000000 --- a/apps/api/src/pages/api/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { NextApiRequest, NextApiResponse } from 'next'; - -const handler = async (req: NextApiRequest, res: NextApiResponse) => { - return res.setHeader('Content-Type', 'text/html').setHeader('Cache-Control', 's-maxage=86400').send('gm'); -}; - -export default handler; diff --git a/apps/api/src/pages/api/metadata/upload.ts b/apps/api/src/pages/api/metadata/upload.ts deleted file mode 100644 index 33f54e803d8f..000000000000 --- a/apps/api/src/pages/api/metadata/upload.ts +++ /dev/null @@ -1,37 +0,0 @@ -import Bundlr from '@bundlr-network/client'; -import { APP_NAME, ERROR_MESSAGE } from 'data/constants'; -import type { NextApiRequest, NextApiResponse } from 'next'; -import { BUNDLR_CURRENCY, BUNDLR_NODE_URL } from 'src/constants'; - -const handler = async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method === 'OPTIONS') { - return res.status(200).end(); - } - - if (req.method !== 'POST') { - return res.status(405).json({ success: false, message: 'Invalid method!' }); - } - - if (!req.body) { - return res.status(400).json({ success: false, message: 'Bad request!' }); - } - - const payload = JSON.stringify(req.body); - - try { - const bundlr = new Bundlr(BUNDLR_NODE_URL, BUNDLR_CURRENCY, process.env.BUNDLR_PRIVATE_KEY); - const tags = [ - { name: 'Content-Type', value: 'application/json' }, - { name: 'App-Name', value: APP_NAME } - ]; - - const uploader = bundlr.uploader.chunkedUploader; - const { data } = await uploader.uploadData(Buffer.from(payload), { tags }); - - return res.status(200).json({ success: true, id: data.id }); - } catch { - return res.status(500).json({ success: false, message: ERROR_MESSAGE }); - } -}; - -export default handler; diff --git a/apps/api/src/pages/api/og.ts b/apps/api/src/pages/api/og.ts deleted file mode 100644 index 9058452d84ff..000000000000 --- a/apps/api/src/pages/api/og.ts +++ /dev/null @@ -1,41 +0,0 @@ -import getProfileMeta from '@lib/api/getProfileMeta'; -import getPublicationMeta from '@lib/api/getPublicationMeta'; -import generateMeta from '@lib/generateMeta'; -import type { NextApiRequest, NextApiResponse } from 'next'; - -const handler = async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method !== 'GET') { - return res.status(405).json({ success: false, message: 'Invalid method!' }); - } - - const uri = req.query.uri as string; - - if (!uri) { - return res.status(400).json({ success: false, message: 'Invalid URI!' }); - } - - const isProfile = uri.includes('/u/'); - const isPost = uri.includes('/posts/'); - - try { - if (isProfile) { - return getProfileMeta(req, res, uri.replace('/u/', '')); - } - - if (isPost) { - return getPublicationMeta(req, res, uri.replace('/posts/', '')); - } - - return res - .setHeader('Content-Type', 'text/html') - .setHeader('Cache-Control', 's-maxage=86400') - .send(generateMeta()); - } catch (error) { - return res - .setHeader('Content-Type', 'text/html') - .setHeader('Cache-Control', 's-maxage=86400') - .send(generateMeta()); - } -}; - -export default handler; diff --git a/apps/api/src/pages/api/sts/token.ts b/apps/api/src/pages/api/sts/token.ts deleted file mode 100644 index f5495a140c2b..000000000000 --- a/apps/api/src/pages/api/sts/token.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { AssumeRoleCommand, STSClient } from '@aws-sdk/client-sts'; -import { ERROR_MESSAGE, EVER_API } from 'data/constants'; -import type { NextApiRequest, NextApiResponse } from 'next'; - -interface Data { - accessKeyId?: string; - secretAccessKey?: string; - sessionToken?: string; - bucketName?: string; - message?: string; - success: boolean; -} - -const accessKeyId = process.env.EVER_ACCESS_KEY as string; -const secretAccessKey = process.env.EVER_ACCESS_SECRET as string; -const bucketName = process.env.NEXT_PUBLIC_EVER_BUCKET_NAME as string; - -const handler = async (req: NextApiRequest, res: NextApiResponse) => { - if (req.method !== 'GET') { - return res.status(405).json({ success: false, message: 'Invalid method!' }); - } - - try { - const stsClient = new STSClient({ - endpoint: EVER_API, - region: 'us-west-2', - credentials: { accessKeyId, secretAccessKey } - }); - const params = { - DurationSeconds: 900, - Policy: `{ - "Version": "2012-10-17", - "Statement": [ - { - "Effect": "Allow", - "Action": [ - "s3:PutObject", - "s3:GetObject" - ], - "Resource": [ - "arn:aws:s3:::${bucketName}/*" - ] - } - ] - }` - }; - - const data = await stsClient.send( - new AssumeRoleCommand({ - ...params, - RoleArn: undefined, - RoleSessionName: undefined - }) - ); - - return res.status(200).json({ - success: true, - accessKeyId: data.Credentials?.AccessKeyId, - secretAccessKey: data.Credentials?.SecretAccessKey, - sessionToken: data.Credentials?.SessionToken - }); - } catch { - return res.status(500).json({ success: false, message: ERROR_MESSAGE }); - } -}; - -export default handler; diff --git a/apps/api/src/routes/account/get.ts b/apps/api/src/routes/account/get.ts new file mode 100644 index 000000000000..cf86119df0bf --- /dev/null +++ b/apps/api/src/routes/account/get.ts @@ -0,0 +1,54 @@ +import { PermissionId } from "@hey/data/permissions"; +import prisma from "@hey/db/prisma/db/client"; +import { getRedis, setRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; +import type { AccountDetails } from "@hey/types/hey"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; +import { noBody } from "src/helpers/responses"; + +export const get = [ + rateLimiter({ requests: 250, within: 1 }), + async (req: Request, res: Response) => { + const { id } = req.query; + + if (!id) { + return noBody(res); + } + + try { + const cacheKey = `account:${id}`; + const cachedData = await getRedis(cacheKey); + + if (cachedData) { + logger.info(`(cached) Account details fetched for ${id}`); + return res + .status(200) + .json({ result: JSON.parse(cachedData), success: true }); + } + + const [accountPermission, accountStatus] = await prisma.$transaction([ + prisma.profilePermission.findFirst({ + where: { + permissionId: PermissionId.Suspended, + profileId: id as string + } + }), + prisma.profileStatus.findUnique({ where: { id: id as string } }) + ]); + + const response: AccountDetails = { + isSuspended: accountPermission?.permissionId === PermissionId.Suspended, + status: accountStatus || null + }; + + await setRedis(cacheKey, response); + logger.info(`Account details fetched for ${id}`); + + return res.status(200).json({ result: response, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/account/status/clear.ts b/apps/api/src/routes/account/status/clear.ts new file mode 100644 index 000000000000..80c331557c4d --- /dev/null +++ b/apps/api/src/routes/account/status/clear.ts @@ -0,0 +1,30 @@ +import prisma from "@hey/db/prisma/db/client"; +import { delRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; +import parseJwt from "@hey/helpers/parseJwt"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; + +export const post = [ + rateLimiter({ requests: 50, within: 1 }), + validateLensAccount, + async (req: Request, res: Response) => { + try { + const idToken = req.headers["x-id-token"] as string; + const payload = parseJwt(idToken); + + const accountStatus = await prisma.profileStatus.deleteMany({ + where: { id: payload.id } + }); + + await delRedis(`account:${payload.id}`); + logger.info(`Cleared profile status for ${payload.id}`); + + return res.status(200).json({ result: accountStatus, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/account/status/update.ts b/apps/api/src/routes/account/status/update.ts new file mode 100644 index 000000000000..fe59ac0aeb14 --- /dev/null +++ b/apps/api/src/routes/account/status/update.ts @@ -0,0 +1,58 @@ +import prisma from "@hey/db/prisma/db/client"; +import { delRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; +import parseJwt from "@hey/helpers/parseJwt"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; +import { invalidBody, noBody } from "src/helpers/responses"; +import { object, string } from "zod"; + +interface ExtensionRequest { + message: string; + emoji: string; +} + +const validationSchema = object({ + message: string().max(80), + emoji: string() +}); + +export const post = [ + rateLimiter({ requests: 50, within: 1 }), + validateLensAccount, + async (req: Request, res: Response) => { + const { body } = req; + + if (!body) { + return noBody(res); + } + + const validation = validationSchema.safeParse(body); + + if (!validation.success) { + return invalidBody(res); + } + + const { message, emoji } = body as ExtensionRequest; + + try { + const idToken = req.headers["x-id-token"] as string; + const payload = parseJwt(idToken); + + const accountStatus = await prisma.profileStatus.upsert({ + create: { message, emoji, id: payload.id }, + update: { message, emoji }, + where: { id: payload.id } + }); + + await delRedis(`account:${payload.id}`); + logger.info(`Updated account status for ${payload.id}`); + + return res.status(200).json({ result: accountStatus, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/ai/translate.ts b/apps/api/src/routes/ai/translate.ts new file mode 100644 index 000000000000..401a0529dd7c --- /dev/null +++ b/apps/api/src/routes/ai/translate.ts @@ -0,0 +1,110 @@ +import lensPg from "@hey/db/lensPg"; +import { generateForeverExpiry, getRedis, setRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import OpenAI from "openai"; +import { zodFunction } from "openai/helpers/zod"; +import catchedError from "src/helpers/catchedError"; +import { CACHE_AGE_INDEFINITE } from "src/helpers/constants"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; +import { invalidBody, noBody } from "src/helpers/responses"; +import { object, string, type z } from "zod"; + +const TEMPLATE = ` +Translate the following text to English. +Examples: Hello, How are you?, I am fine, thank you. +Return only the translation in English. +Keep the markdown formatting including line breaks. +Never change the @ mentions, hashtags, links, or any other special characters. +Text: {text} +`; + +interface ExtensionRequest { + id: string; +} + +const validationSchema = object({ + id: string() +}); + +export const post = [ + rateLimiter({ requests: 50, within: 1 }), + validateLensAccount, + async (req: Request, res: Response) => { + const { body } = req; + + if (!body) { + return noBody(res); + } + + const validation = validationSchema.safeParse(body); + + if (!validation.success) { + return invalidBody(res); + } + + const { id } = body as ExtensionRequest; + + try { + const cacheKey = `ai:translation:${id}`; + const cachedData = await getRedis(cacheKey); + + if (cachedData) { + logger.info(`(cached) AI Translation fetched for ${id}`); + return res + .status(200) + .setHeader("Cache-Control", CACHE_AGE_INDEFINITE) + .json({ result: JSON.parse(cachedData), success: true }); + } + + const postMetadata = await lensPg.query( + "SELECT content FROM publication.metadata WHERE publication_id = $1", + [id] + ); + + const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }); + const translatedResponseSchema = object({ + translated: string().describe("The translated text") + }); + const response = await openai.beta.chat.completions.parse({ + model: "gpt-3.5-turbo", + messages: [ + { + role: "user", + content: TEMPLATE.replace("{text}", postMetadata[0].content) + } + ], + tools: [ + zodFunction({ + name: "translate", + parameters: translatedResponseSchema + }) + ] + }); + + type responseSchema = z.infer; + const translated = response.choices[0].message.tool_calls[0].function + .parsed_arguments as responseSchema; + + const finalResult = { + original: postMetadata[0].content, + ...translated + }; + + await setRedis( + cacheKey, + JSON.stringify(finalResult), + generateForeverExpiry() + ); + logger.info(`AI Translation fetched for ${id}`); + + return res + .status(200) + .setHeader("Cache-Control", CACHE_AGE_INDEFINITE) + .json({ result: finalResult, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/analytics/impressions.ts b/apps/api/src/routes/analytics/impressions.ts new file mode 100644 index 000000000000..ffff6cfb6363 --- /dev/null +++ b/apps/api/src/routes/analytics/impressions.ts @@ -0,0 +1,61 @@ +import clickhouseClient from "@hey/db/clickhouseClient"; +import { generateLongExpiry, getRedis, setRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; +import parseJwt from "@hey/helpers/parseJwt"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { CACHE_AGE_30_MINS } from "src/helpers/constants"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; + +export const get = [ + rateLimiter({ requests: 250, within: 1 }), + validateLensAccount, + async (req: Request, res: Response) => { + try { + const idToken = req.headers["x-id-token"] as string; + const payload = parseJwt(idToken); + + const cacheKey = `analytics:impressions:${payload.id}`; + const cachedData = await getRedis(cacheKey); + + if (cachedData) { + logger.info(`(cached) Analytics impressions fetched for ${payload.id}`); + return res + .status(200) + .setHeader("Cache-Control", CACHE_AGE_30_MINS) + .json({ result: JSON.parse(cachedData), success: true }); + } + + const impressions = await clickhouseClient.query({ + format: "JSONEachRow", + query: ` + WITH + arrayJoin( + arrayMap(x -> toDate(now()) - x, range(30)) + ) AS date_range + SELECT + date_range AS date, + countIf(toDate(viewed) = date_range) AS impressions + FROM impressions + WHERE substring(publication, 1, position(publication, '-') - 1) = '${payload.id}' + AND viewed >= now() - INTERVAL 30 DAY + GROUP BY date_range + ORDER BY date_range; + ` + }); + + const result = await impressions.json(); + + await setRedis(cacheKey, JSON.stringify(result), generateLongExpiry()); + logger.info(`Analytics impressions fetched for ${payload.id}`); + + return res + .status(200) + .setHeader("Cache-Control", CACHE_AGE_30_MINS) + .json({ result, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/analytics/overview.ts b/apps/api/src/routes/analytics/overview.ts new file mode 100644 index 000000000000..361d80604653 --- /dev/null +++ b/apps/api/src/routes/analytics/overview.ts @@ -0,0 +1,188 @@ +import lensPg from "@hey/db/lensPg"; +import { generateLongExpiry, getRedis, setRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; +import parseJwt from "@hey/helpers/parseJwt"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { CACHE_AGE_30_MINS } from "src/helpers/constants"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; + +interface TransformedRecord { + date: string; + likes: number; + comments: number; + collects: number; + mirrors: number; + quotes: number; + mentions: number; + follows: number; + bookmarks: number; +} + +const generateLast30Days = (): string[] => { + const days = []; + const today = new Date(); + for (let i = 0; i < 30; i++) { + const day = new Date(today); + day.setDate(today.getDate() - i); + days.push(day.toISOString().split("T")[0]); + } + return days.reverse(); +}; + +const mapData = (data: any[], key: string): Record => { + return data.reduce((map: Record, item) => { + map[item.date] = Number.parseInt(item[key], 10); + return map; + }, {}); +}; + +const transformData = (result: any[][]): TransformedRecord[] => { + const [ + likesData, + commentsData, + collectsData, + mirrorsData, + quotesData, + mentionsData, + followsData, + bookmarksData + ] = result; + + // Generate maps for quick lookup + const likesMap = mapData(likesData, "likes"); + const commentsMap = mapData(commentsData, "comments"); + const collectsMap = mapData(collectsData, "collects"); + const mirrorsMap = mapData(mirrorsData, "mirrors"); + const quotesMap = mapData(quotesData, "quotes"); + const mentionsMap = mapData(mentionsData, "mentions"); + const followsMap = mapData(followsData, "follows"); + const bookmarksMap = mapData(bookmarksData, "bookmarks"); + + // Transform data for the last 30 days + const last30Days = generateLast30Days(); + return last30Days.map((date) => ({ + date, + likes: likesMap[date] || 0, + comments: commentsMap[date] || 0, + collects: collectsMap[date] || 0, + mirrors: mirrorsMap[date] || 0, + quotes: quotesMap[date] || 0, + mentions: mentionsMap[date] || 0, + follows: followsMap[date] || 0, + bookmarks: bookmarksMap[date] || 0 + })); +}; + +export const get = [ + rateLimiter({ requests: 250, within: 1 }), + validateLensAccount, + async (req: Request, res: Response) => { + try { + const idToken = req.headers["x-id-token"] as string; + const payload = parseJwt(idToken); + + const cacheKey = `analytics:overview:${payload.id}`; + const cachedData = await getRedis(cacheKey); + + if (cachedData) { + logger.info(`(cached) Analytics overview fetched for ${payload.id}`); + return res + .status(200) + .setHeader("Cache-Control", CACHE_AGE_30_MINS) + .json({ result: JSON.parse(cachedData), success: true }); + } + + const result = await lensPg.multi( + ` + -- Get number of likes per day for the last 30 days + SELECT TO_CHAR(date_trunc('day', notification_action_date), 'YYYY-MM-DD') AS date, COUNT(*) AS likes + FROM notification.record + WHERE notification_type = 'REACTED' + AND notification_action_date >= NOW() - INTERVAL '30 days' + AND notification_receiving_profile_id = $1 + GROUP BY date_trunc('day', notification_action_date) + ORDER BY date; + + -- Get number of comments per day for the last 30 days + SELECT TO_CHAR(date_trunc('day', notification_action_date), 'YYYY-MM-DD') AS date, COUNT(*) AS comments + FROM notification.record + WHERE notification_type = 'COMMENTED' + AND notification_action_date >= NOW() - INTERVAL '30 days' + AND notification_receiving_profile_id = $1 + GROUP BY date_trunc('day', notification_action_date) + ORDER BY date; + + -- Get number of collects per day for the last 30 days + SELECT TO_CHAR(date_trunc('day', notification_action_date), 'YYYY-MM-DD') AS date, COUNT(*) AS collects + FROM notification.record + WHERE notification_type = 'ACTED' + AND notification_action_date >= NOW() - INTERVAL '30 days' + AND notification_receiving_profile_id = $1 + GROUP BY date_trunc('day', notification_action_date) + ORDER BY date; + + -- Get number of mirrors per day for the last 30 days + SELECT TO_CHAR(date_trunc('day', notification_action_date), 'YYYY-MM-DD') AS date, COUNT(*) AS mirrors + FROM notification.record + WHERE notification_type = 'MIRRORED' + AND notification_action_date >= NOW() - INTERVAL '30 days' + AND notification_receiving_profile_id = $1 + GROUP BY date_trunc('day', notification_action_date) + ORDER BY date; + + -- Get number of quotes per day for the last 30 days + SELECT TO_CHAR(date_trunc('day', notification_action_date), 'YYYY-MM-DD') AS date, COUNT(*) AS quotes + FROM notification.record + WHERE notification_type = 'QUOTED' + AND notification_action_date >= NOW() - INTERVAL '30 days' + AND notification_receiving_profile_id = $1 + GROUP BY date_trunc('day', notification_action_date) + ORDER BY date; + + -- Get number of mentions per day for the last 30 days + SELECT TO_CHAR(date_trunc('day', notification_action_date), 'YYYY-MM-DD') AS date, COUNT(*) AS mentions + FROM notification.record + WHERE notification_type = 'MENTIONED' + AND notification_action_date >= NOW() - INTERVAL '30 days' + AND notification_receiving_profile_id = $1 + GROUP BY date_trunc('day', notification_action_date) + ORDER BY date; + + -- Get number of follows per day for the last 30 days + SELECT TO_CHAR(date_trunc('day', notification_action_date), 'YYYY-MM-DD') AS date, COUNT(*) AS follows + FROM notification.record + WHERE notification_type = 'FOLLOWED' + AND notification_action_date >= NOW() - INTERVAL '30 days' + AND notification_receiving_profile_id = $1 + GROUP BY date_trunc('day', notification_action_date) + ORDER BY date; + + -- Get number of bookmarks per day for the last 30 days + SELECT TO_CHAR(date_trunc('day', bookmarked_at), 'YYYY-MM-DD') AS date, COUNT(*) AS bookmarks + FROM personalisation.bookmarked_publication + WHERE bookmarked_at >= NOW() - INTERVAL '30 days' + AND SPLIT_PART(publication_id, '-', 1) = $1 + GROUP BY date_trunc('day', bookmarked_at) + ORDER BY date; + `, + [payload.id] + ); + + await setRedis( + cacheKey, + JSON.stringify(transformData(result)), + generateLongExpiry() + ); + logger.info(`Analytics overview fetched for ${payload.id}`); + + return res + .status(200) + .setHeader("Cache-Control", CACHE_AGE_30_MINS) + .json({ result: transformData(result), success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/avatar.ts b/apps/api/src/routes/avatar.ts new file mode 100644 index 000000000000..47e0b2e4d89c --- /dev/null +++ b/apps/api/src/routes/avatar.ts @@ -0,0 +1,53 @@ +import { LensHub } from "@hey/abis"; +import { IPFS_GATEWAY, IS_MAINNET, LENS_HUB } from "@hey/data/constants"; +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import { CACHE_AGE_INDEFINITE } from "src/helpers/constants"; +import getRpc from "src/helpers/getRpc"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; +import { noBody } from "src/helpers/responses"; +import { createPublicClient } from "viem"; +import { polygon, polygonAmoy } from "viem/chains"; + +export const get = [ + rateLimiter({ requests: 2000, within: 1 }), + async (req: Request, res: Response) => { + const { id } = req.query; + + if (!id) { + return noBody(res); + } + + try { + const client = createPublicClient({ + chain: IS_MAINNET ? polygon : polygonAmoy, + transport: getRpc({ mainnet: IS_MAINNET }) + }); + + const data: any = await client.readContract({ + abi: LensHub, + address: LENS_HUB, + args: [id], + functionName: "tokenURI" + }); + + const jsonData = JSON.parse( + Buffer.from(data.split(",")[1], "base64").toString() + ); + + const base64Image = jsonData.image.split(";base64,").pop(); + const svgImage = Buffer.from(base64Image, "base64").toString("utf-8"); + + logger.info(`Downloaded Lenny avatar for ${id}`); + + return res + .status(200) + .setHeader("Cache-Control", CACHE_AGE_INDEFINITE) + .type("svg") + .send(svgImage); + } catch { + const url = `${IPFS_GATEWAY}/Qmb4XppdMDCsS7KCL8nCJo8pukEWeqL4bTghURYwYiG83i/cropped_image.png`; + return res.status(302).redirect(url); + } + } +]; diff --git a/apps/api/src/routes/badges/hasHeyNft.ts b/apps/api/src/routes/badges/hasHeyNft.ts new file mode 100644 index 000000000000..95c20467c904 --- /dev/null +++ b/apps/api/src/routes/badges/hasHeyNft.ts @@ -0,0 +1,73 @@ +import { HEY_MEMBERSHIP_NFT_POST_ID } from "@hey/data/constants"; +import lensPg from "@hey/db/lensPg"; +import { getRedis, setRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { CACHE_AGE_INDEFINITE } from "src/helpers/constants"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; +import { noBody } from "src/helpers/responses"; +import type { Address } from "viem"; +import { getAddress } from "viem"; + +export const get = [ + rateLimiter({ requests: 100, within: 1 }), + async (req: Request, res: Response) => { + const { address, id } = req.query; + + if (!id && !address) { + return noBody(res); + } + + try { + const formattedAddress = address + ? getAddress(address as Address) + : undefined; + + const cacheKey = `badge:hey-membership-nft:${id || address}`; + const cachedData = await getRedis(cacheKey); + + if (cachedData) { + logger.info( + `(cached) Hey NFT badge fetched for ${id || formattedAddress}` + ); + + return res + .status(200) + .setHeader("Cache-Control", CACHE_AGE_INDEFINITE) + .json({ hasHeyNft: true, success: true }); + } + + const openActionModuleActed = await lensPg.query( + ` + SELECT EXISTS ( + SELECT 1 + FROM profile_view pv + JOIN publication.open_action_module_acted_record o ON pv.profile_id = o.acted_profile_id + WHERE + (pv.profile_id = $1 OR pv.owned_by = $2) + AND o.publication_id = $3 + ) AS exists; + `, + [id, formattedAddress, HEY_MEMBERSHIP_NFT_POST_ID] + ); + + const hasHeyNft = openActionModuleActed[0]?.exists; + + if (hasHeyNft) { + await setRedis(cacheKey, hasHeyNft); + } + logger.info(`Hey NFT badge fetched for ${id || formattedAddress}`); + + return res + .status(200) + .setHeader( + "Cache-Control", + hasHeyNft ? CACHE_AGE_INDEFINITE : "no-cache" + ) + .json({ hasHeyNft, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/badges/isHeyAccount.ts b/apps/api/src/routes/badges/isHeyAccount.ts new file mode 100644 index 000000000000..f1297267caf8 --- /dev/null +++ b/apps/api/src/routes/badges/isHeyAccount.ts @@ -0,0 +1,73 @@ +import { HEY_LENS_SIGNUP } from "@hey/data/constants"; +import lensPg from "@hey/db/lensPg"; +import { getRedis, setRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { CACHE_AGE_INDEFINITE } from "src/helpers/constants"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; +import { noBody } from "src/helpers/responses"; +import type { Address } from "viem"; +import { getAddress } from "viem"; + +export const get = [ + rateLimiter({ requests: 100, within: 1 }), + async (req: Request, res: Response) => { + const { address, id } = req.query; + + if (!id && !address) { + return noBody(res); + } + + try { + const formattedAddress = address + ? getAddress(address as Address) + : undefined; + + const cacheKey = `badge:hey-account:${id || address}`; + const cachedData = await getRedis(cacheKey); + + if (cachedData === "true") { + logger.info( + `(cached) Hey account badge fetched for ${id || formattedAddress}` + ); + + return res + .status(200) + .setHeader("Cache-Control", CACHE_AGE_INDEFINITE) + .json({ isHeyAccount: true, success: true }); + } + + const onboardingAccount = await lensPg.query( + ` + SELECT EXISTS ( + SELECT 1 + FROM profile_view pv + JOIN app.onboarding_profile o ON pv.profile_id = o.profile_id + WHERE + (pv.profile_id = $1 OR pv.owned_by = $2) + AND o.onboarded_by_address = $3 + ) AS exists; + `, + [id, formattedAddress, HEY_LENS_SIGNUP] + ); + + const isHeyAccount = onboardingAccount[0]?.exists; + + if (isHeyAccount) { + await setRedis(cacheKey, isHeyAccount); + } + logger.info(`Hey account badge fetched for ${id || formattedAddress}`); + + return res + .status(200) + .setHeader( + "Cache-Control", + isHeyAccount ? CACHE_AGE_INDEFINITE : "no-cache" + ) + .json({ isHeyAccount, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/email/update.ts b/apps/api/src/routes/email/update.ts new file mode 100644 index 000000000000..5dc3834eb6de --- /dev/null +++ b/apps/api/src/routes/email/update.ts @@ -0,0 +1,106 @@ +import { APP_NAME } from "@hey/data/constants"; +import prisma from "@hey/db/prisma/db/client"; +import { delRedis } from "@hey/db/redisClient"; +import isEmailAllowed from "@hey/helpers/isEmailAllowed"; +import logger from "@hey/helpers/logger"; +import parseJwt from "@hey/helpers/parseJwt"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import sendEmail from "src/helpers/email/sendEmail"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; +import { invalidBody, noBody } from "src/helpers/responses"; +import { v4 as uuid } from "uuid"; +import { boolean, object, string } from "zod"; + +interface ExtensionRequest { + email: string; + resend?: boolean; +} + +const validationSchema = object({ + email: string().email(), + resend: boolean().optional() +}); + +// TODO: Throw if emails is already in use +export const post = [ + rateLimiter({ requests: 50, within: 60 }), + validateLensAccount, + async (req: Request, res: Response) => { + const { body } = req; + + if (!body) { + return noBody(res); + } + + const validation = validationSchema.safeParse(body); + + if (!validation.success) { + return invalidBody(res); + } + + const { email, resend } = body as ExtensionRequest; + + try { + if (!isEmailAllowed(email)) { + return res + .status(400) + .json({ error: "Email not allowed", success: false }); + } + + const idToken = req.headers["x-id-token"] as string; + const payload = parseJwt(idToken); + + if (!resend) { + const foundEmail = await prisma.email.findUnique({ + where: { id: payload.id } + }); + + if (foundEmail?.email === email) { + return res.status(200).json({ success: false }); + } + } + + const baseData = { + email: email.toLowerCase(), + tokenExpiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours + verificationToken: uuid(), + verified: false + }; + + const upsertedEmail = await prisma.email.upsert({ + create: { id: payload.id, ...baseData }, + update: baseData, + where: { id: payload.id } + }); + + sendEmail({ + body: ` + + +

Welcome to Hey!

+
+

Please click the link below to verify your email address: ${upsertedEmail.email}

+ Verify Email → +
+

If you didn't request this email, please ignore this email.

+
+

Thanks,

+

${APP_NAME} team

+ + + `, + recipient: upsertedEmail.email, + subject: `Verify your ${APP_NAME} email address` + }); + + await delRedis(`preference:${payload.id}`); + logger.info(`Email updated to ${email} for ${payload.id}`); + + return res.status(200).json({ success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/email/verify.ts b/apps/api/src/routes/email/verify.ts new file mode 100644 index 000000000000..238ea9136044 --- /dev/null +++ b/apps/api/src/routes/email/verify.ts @@ -0,0 +1,27 @@ +import prisma from "@hey/db/prisma/db/client"; +import { delRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import { noBody } from "src/helpers/responses"; + +export const get = async (req: Request, res: Response) => { + const { token } = req.query; + + if (!token) { + return noBody(res); + } + + try { + const updatedEmail = await prisma.email.update({ + data: { tokenExpiresAt: null, verificationToken: null, verified: true }, + where: { verificationToken: token as string } + }); + + await delRedis(`preference:${updatedEmail.id}`); + logger.info(`Email verified for ${updatedEmail.email}`); + + return res.redirect("https://hey.xyz"); + } catch { + return res.status(400).send("Something went wrong"); + } +}; diff --git a/apps/api/src/routes/ens/index.ts b/apps/api/src/routes/ens/index.ts new file mode 100644 index 000000000000..ce1de3d69ccd --- /dev/null +++ b/apps/api/src/routes/ens/index.ts @@ -0,0 +1,60 @@ +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { resolverAbi } from "src/helpers/ens/resolverAbi"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; +import { invalidBody, noBody } from "src/helpers/responses"; +import { http, createPublicClient, fallback } from "viem"; +import { mainnet } from "viem/chains"; +import { array, object, string } from "zod"; + +interface ExtensionRequest { + addresses: string[]; +} + +const validationSchema = object({ + addresses: array(string().regex(/^(0x)?[\da-f]{40}$/i)).max(100) +}); + +export const post = [ + rateLimiter({ requests: 100, within: 1 }), + async (req: Request, res: Response) => { + const { body } = req; + + if (!body) { + return noBody(res); + } + + const validation = validationSchema.safeParse(body); + + if (!validation.success) { + return invalidBody(res); + } + + const { addresses } = body as ExtensionRequest; + + try { + const client = createPublicClient({ + chain: mainnet, + transport: fallback([ + http("https://ethereum.publicnode.com"), + http("https://rpc.ankr.com/eth"), + http("https://cloudflare-eth.com"), + http("https://eth.merkle.io") + ]) + }); + + const result = await client.readContract({ + abi: resolverAbi, + address: "0x3671ae578e63fdf66ad4f3e12cc0c0d71ac7510c", + args: [addresses], + functionName: "getNames" + }); + logger.info("ENS names fetched"); + + return res.status(200).json({ result, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/export/collects.ts b/apps/api/src/routes/export/collects.ts new file mode 100644 index 000000000000..f25a45ec5536 --- /dev/null +++ b/apps/api/src/routes/export/collects.ts @@ -0,0 +1,60 @@ +import { Errors } from "@hey/data/errors"; +import lensPg from "@hey/db/lensPg"; +import logger from "@hey/helpers/logger"; +import parseJwt from "@hey/helpers/parseJwt"; +import { Parser } from "@json2csv/plainjs"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { CACHE_AGE_30_MINS } from "src/helpers/constants"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; +import { noBody } from "src/helpers/responses"; + +export const get = [ + rateLimiter({ requests: 10, within: 1 }), + validateLensAccount, + async (req: Request, res: Response) => { + const { id } = req.query; + + if (!id) { + return noBody(res); + } + + try { + const idToken = req.headers["x-id-token"] as string; + const payload = parseJwt(idToken); + const targetAccountId = (id as string).split("-")[0]; + + if (payload.id !== targetAccountId) { + return catchedError(res, new Error(Errors.Unauthorized), 401); + } + + const openActionModuleCollectNftOwnership = await lensPg.query( + ` + SELECT po.owner_address as address + FROM publication.open_action_module_collect_nft_ownership AS po + WHERE po.publication_id = $1; + `, + [id] + ); + + const fields = ["address"]; + const parser = new Parser({ fields }); + const csv = parser.parse(openActionModuleCollectNftOwnership); + + logger.info(`[Lens] Exported collect addresses list for ${id}`); + + return res + .status(200) + .setHeader("Content-Type", "text/csv") + .setHeader( + "Content-Disposition", + `attachment; filename="collect_addresses_${id}.csv"` + ) + .setHeader("Cache-Control", CACHE_AGE_30_MINS) + .send(csv); + } catch (error) { + catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/frames/post.ts b/apps/api/src/routes/frames/post.ts new file mode 100644 index 000000000000..2ed69effe30b --- /dev/null +++ b/apps/api/src/routes/frames/post.ts @@ -0,0 +1,150 @@ +import { IS_MAINNET } from "@hey/data/constants"; +import logger from "@hey/helpers/logger"; +import parseJwt from "@hey/helpers/parseJwt"; +import type { ButtonType } from "@hey/types/misc"; +import type { Request, Response } from "express"; +import { parseHTML } from "linkedom"; +import catchedError from "src/helpers/catchedError"; +import { HEY_USER_AGENT } from "src/helpers/constants"; +import signFrameAction from "src/helpers/frames/signFrameAction"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; +import getFrame from "src/helpers/oembed/meta/getFrame"; +import { invalidBody, noBody } from "src/helpers/responses"; +import { number, object, string } from "zod"; + +interface ExtensionRequest { + actionResponse?: string; + buttonAction?: ButtonType; + buttonIndex: number; + inputText?: string; + postUrl: string; + pubId: string; + state?: string; +} + +const validationSchema = object({ + buttonAction: string().optional(), + buttonIndex: number(), + postUrl: string(), + pubId: string() +}); + +// TODO: Add tests +export const post = [ + rateLimiter({ requests: 100, within: 1 }), + validateLensAccount, + async (req: Request, res: Response) => { + const { body } = req; + + if (!body) { + return noBody(res); + } + + const validation = validationSchema.safeParse(body); + + if (!validation.success) { + return invalidBody(res); + } + + const { + actionResponse, + buttonAction, + buttonIndex, + inputText, + postUrl, + pubId, + state + } = body as ExtensionRequest; + + try { + const accessToken = req.headers["x-access-token"] as string; + const identityToken = req.headers["x-id-token"] as string; + const payload = parseJwt(identityToken); + const { id } = payload; + + let request = { + actionResponse: actionResponse || "", + buttonIndex, + inputText: inputText || "", + profileId: id, + pubId, + specVersion: "1.0.0", + state: state || "", + url: postUrl + }; + + let signature = ""; + + // Sign request if Frame accepts Lens authenticated response + if (req.body.acceptsLens) { + const signatureResponse = await signFrameAction( + request, + accessToken, + IS_MAINNET ? "mainnet" : "testnet" + ); + if (signatureResponse) { + signature = signatureResponse.signature; + request = signatureResponse.signedTypedData.value; + } + } + + const trustedData = { messageBytes: signature }; + const untrustedData = { + identityToken, + unixTimestamp: Math.floor(Date.now() / 1000), + ...request + }; + + const response = await fetch(postUrl, { + body: JSON.stringify({ + clientProtocol: "lens@1.0.0", + trustedData, + untrustedData + }), + headers: { + "Content-Type": "application/json", + "User-Agent": HEY_USER_AGENT + }, + method: "POST", + redirect: buttonAction === "post_redirect" ? "manual" : undefined + }); + + const { status } = response; + const { headers } = response; + + let result = {}; + if (status !== 302) { + if ( + response.headers.get("content-type")?.includes("application/json") + ) { + result = await response.json(); + } else { + result = await response.text(); + } + } + + logger.info(`Open frame button clicked by ${id} on ${postUrl}`); + + if (buttonAction === "tx") { + return res + .status(200) + .json({ frame: { transaction: result }, success: true }); + } + + if (buttonAction === "post_redirect" && status === 302) { + return res + .status(200) + .json({ frame: { location: headers.get("location") } }); + } + + const { document } = parseHTML(result); + + return res + .status(200) + .json({ frame: getFrame(document, postUrl), success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/health.ts b/apps/api/src/routes/health.ts new file mode 100644 index 000000000000..34c95ad12860 --- /dev/null +++ b/apps/api/src/routes/health.ts @@ -0,0 +1,5 @@ +import type { Request, Response } from "express"; + +export const get = async (_: Request, res: Response) => { + return res.json({ ping: "pong" }); +}; diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts new file mode 100644 index 000000000000..478189f96b51 --- /dev/null +++ b/apps/api/src/routes/index.ts @@ -0,0 +1,5 @@ +import type { Request, Response } from "express"; + +export const get = async (_: Request, res: Response) => { + return res.json({ message: "Hey API ✨" }); +}; diff --git a/apps/api/src/routes/internal/account/get.ts b/apps/api/src/routes/internal/account/get.ts new file mode 100644 index 000000000000..c9a0d614d899 --- /dev/null +++ b/apps/api/src/routes/internal/account/get.ts @@ -0,0 +1,61 @@ +import prisma from "@hey/db/prisma/db/client"; +import logger from "@hey/helpers/logger"; +import type { AccountTheme, InternalAccount } from "@hey/types/hey"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import validateHasCreatorToolsAccess from "src/helpers/middlewares/validateHasCreatorToolsAccess"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; +import { noBody } from "src/helpers/responses"; + +export const get = [ + validateLensAccount, + validateHasCreatorToolsAccess, + async (req: Request, res: Response) => { + const { id } = req.query; + + if (!id) { + return noBody(res); + } + + try { + const [preference, permissions, email, membershipNft, theme, mutedWords] = + await prisma.$transaction([ + prisma.preference.findUnique({ where: { id: id as string } }), + prisma.profilePermission.findMany({ + include: { permission: { select: { key: true } } }, + where: { enabled: true, profileId: id as string } + }), + prisma.email.findUnique({ where: { id: id as string } }), + prisma.membershipNft.findUnique({ where: { id: id as string } }), + prisma.profileTheme.findUnique({ where: { id: id as string } }), + prisma.mutedWord.findMany({ where: { profileId: id as string } }) + ]); + + const response: InternalAccount = { + appIcon: preference?.appIcon || 0, + email: email?.email || null, + emailVerified: Boolean(email?.verified), + hasDismissedOrMintedMembershipNft: Boolean( + membershipNft?.dismissedOrMinted + ), + highSignalNotificationFilter: Boolean( + preference?.highSignalNotificationFilter + ), + developerMode: Boolean(preference?.developerMode), + theme: (theme as AccountTheme) || null, + permissions: permissions.map(({ permission }) => permission.key), + mutedWords: mutedWords.map(({ id, word, expiresAt }) => ({ + id, + word, + expiresAt + })) + }; + + logger.info(`Internal account fetched for ${id}`); + + return res.status(200).json({ result: response, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/internal/account/report.ts b/apps/api/src/routes/internal/account/report.ts new file mode 100644 index 000000000000..f97a459d6092 --- /dev/null +++ b/apps/api/src/routes/internal/account/report.ts @@ -0,0 +1,70 @@ +import lensPg from "@hey/db/lensPg"; +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import validateIsStaff from "src/helpers/middlewares/validateIsStaff"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; +import { invalidBody, noBody } from "src/helpers/responses"; +import { array, object, string } from "zod"; +import { reportPost } from "../gardener/report"; + +interface ExtensionRequest { + id: string; + subreasons: string[]; +} + +const validationSchema = object({ + id: string(), + subreasons: array(string()) +}); + +// TODO: Add test cases +export const post = [ + validateLensAccount, + validateIsStaff, + async (req: Request, res: Response) => { + const { body } = req; + + if (!body) { + return noBody(res); + } + + const validation = validationSchema.safeParse(body); + + if (!validation.success) { + return invalidBody(res); + } + + const { id, subreasons } = body as ExtensionRequest; + + try { + const accessToken = req.headers["x-access-token"] as string; + const post = await lensPg.query( + ` + SELECT publication_id AS id + FROM publication.record + WHERE profile_id = $1 + AND publication_type = 'POST' + LIMIT 1 + `, + [id] + ); + + const postId = post[0]?.id; + + if (!postId) { + return res.status(404).json({ + result: "Nothing to report", + success: true + }); + } + + await reportPost(postId, subreasons, accessToken); + logger.info(`[Lens] Reported account ${id}`); + + return res.status(200).json({ result: postId, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/internal/creator-tools/assign.ts b/apps/api/src/routes/internal/creator-tools/assign.ts new file mode 100644 index 000000000000..c16b111bb709 --- /dev/null +++ b/apps/api/src/routes/internal/creator-tools/assign.ts @@ -0,0 +1,66 @@ +import prisma from "@hey/db/prisma/db/client"; +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import validateHasCreatorToolsAccess from "src/helpers/middlewares/validateHasCreatorToolsAccess"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; +import { invalidBody, noBody } from "src/helpers/responses"; +import { boolean, object, string } from "zod"; +import { postUpdateTasks } from "../permissions/assign"; + +interface ExtensionRequest { + enabled: boolean; + id: string; + accountId: string; +} + +const validationSchema = object({ + enabled: boolean(), + id: string(), + accountId: string() +}); + +// TODO: Merge this with the one in permissions/assign +export const post = [ + validateLensAccount, + validateHasCreatorToolsAccess, + async (req: Request, res: Response) => { + const { body } = req; + + if (!body) { + return noBody(res); + } + + const validation = validationSchema.safeParse(body); + + if (!validation.success) { + return invalidBody(res); + } + + const { enabled, id, accountId } = body as ExtensionRequest; + + try { + if (enabled) { + await prisma.profilePermission.create({ + data: { permissionId: id, profileId: accountId } + }); + + await postUpdateTasks(accountId, id, true); + logger.info(`Enabled permissions for ${accountId}`); + + return res.status(200).json({ enabled, success: true }); + } + + await prisma.profilePermission.deleteMany({ + where: { permissionId: id as string, profileId: accountId as string } + }); + + await postUpdateTasks(accountId, id, false); + logger.info(`Disabled permissions for ${accountId}`); + + return res.status(200).json({ enabled, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/internal/gardener/report.ts b/apps/api/src/routes/internal/gardener/report.ts new file mode 100644 index 000000000000..babd732dc4db --- /dev/null +++ b/apps/api/src/routes/internal/gardener/report.ts @@ -0,0 +1,84 @@ +import logger from "@hey/helpers/logger"; +import { ReportPublicationDocument } from "@hey/lens"; +import { addTypenameToDocument } from "apollo-utilities"; +import axios from "axios"; +import type { Request, Response } from "express"; +import { print } from "graphql"; +import catchedError from "src/helpers/catchedError"; +import validateIsGardener from "src/helpers/middlewares/validateIsGardener"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; +import { invalidBody, noBody } from "src/helpers/responses"; +import { array, object, string } from "zod"; + +export const reportPost = async ( + id: string, + subreasons: string[], + accessToken: string +) => { + return await Promise.all( + subreasons.map(async (subreason: string) => { + await axios.post( + "https://api-v2.lens.dev", + { + operationName: "ReportPublication", + query: print(addTypenameToDocument(ReportPublicationDocument)), + variables: { + request: { + for: id, + reason: { spamReason: { reason: "SPAM", subreason } } + } + } + }, + { + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${accessToken}` + } + } + ); + }) + ); +}; + +interface ExtensionRequest { + id: string; + subreasons: string[]; +} + +const validationSchema = object({ + id: string(), + subreasons: array(string()) +}); + +// TODO: Add test cases +export const post = [ + validateLensAccount, + validateIsGardener, + async (req: Request, res: Response) => { + const { body } = req; + + if (!body) { + return noBody(res); + } + + const validation = validationSchema.safeParse(body); + + if (!validation.success) { + return invalidBody(res); + } + + const { id, subreasons } = body as ExtensionRequest; + + try { + const accessToken = req.headers["x-access-token"] as string; + await reportPost(id, subreasons, accessToken); + logger.info(`[Lens] Reported post ${id}`); + + return res + .status(200) + .json({ result: { reported: subreasons }, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/internal/leafwatch/account/details.ts b/apps/api/src/routes/internal/leafwatch/account/details.ts new file mode 100644 index 000000000000..cde71ff4ecb9 --- /dev/null +++ b/apps/api/src/routes/internal/leafwatch/account/details.ts @@ -0,0 +1,71 @@ +import clickhouseClient from "@hey/db/clickhouseClient"; +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import validateIsStaff from "src/helpers/middlewares/validateIsStaff"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; +import { noBody } from "src/helpers/responses"; + +export const get = [ + validateLensAccount, + validateIsStaff, + async (req: Request, res: Response) => { + const { id } = req.query; + + if (!id) { + return noBody(res); + } + + try { + const details = await clickhouseClient.query({ + format: "JSONEachRow", + query: ` + WITH events_counts AS ( + SELECT + actor, + country, + city, + browser, + COUNT() AS cnt + FROM events + WHERE actor = '${id}' + GROUP BY actor, country, city, browser + ) + SELECT + actor, + argMax(country, cnt) AS most_common_country, + argMax(city, cnt) AS most_common_city, + SUM(cnt) AS number_of_events, + argMax(browser, cnt) AS mostCommonBrowser + FROM events_counts + WHERE actor = '${id}' + GROUP BY actor; + ` + }); + + const result = await details.json<{ + actor: string; + mostCommonBrowser: string; + mostCommonCity: string; + mostCommonCountry: string; + numberOfEvents: string; + }>(); + logger.info(`Account details fetched for ${id}`); + + return res.status(200).json({ + result: result[0] + ? { + actor: result[0].actor, + browser: result[0].mostCommonBrowser, + city: result[0].mostCommonCity, + country: result[0].mostCommonCountry, + events: Number.parseInt(result[0].numberOfEvents) + } + : null, + success: true + }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/internal/leafwatch/account/haveUsedHey.ts b/apps/api/src/routes/internal/leafwatch/account/haveUsedHey.ts new file mode 100644 index 000000000000..82215af2c5b0 --- /dev/null +++ b/apps/api/src/routes/internal/leafwatch/account/haveUsedHey.ts @@ -0,0 +1,34 @@ +import clickhouseClient from "@hey/db/clickhouseClient"; +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import validateIsStaff from "src/helpers/middlewares/validateIsStaff"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; +import { noBody } from "src/helpers/responses"; + +export const get = [ + validateLensAccount, + validateIsStaff, + async (req: Request, res: Response) => { + const { id } = req.query; + + if (!id) { + return noBody(res); + } + + try { + const eventsCount = await clickhouseClient.query({ + format: "JSONEachRow", + query: `SELECT count(*) as count FROM events WHERE actor = '${id}';` + }); + const result = await eventsCount.json<{ count: number }>(); + logger.info("Have used hey status fetched"); + + return res + .status(200) + .json({ haveUsedHey: Number(result[0].count) > 0, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/internal/leafwatch/account/impressions.ts b/apps/api/src/routes/internal/leafwatch/account/impressions.ts new file mode 100644 index 000000000000..e374a8b42f6c --- /dev/null +++ b/apps/api/src/routes/internal/leafwatch/account/impressions.ts @@ -0,0 +1,66 @@ +import clickhouseClient from "@hey/db/clickhouseClient"; +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import validateIsStaff from "src/helpers/middlewares/validateIsStaff"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; +import { noBody } from "src/helpers/responses"; + +export const get = [ + validateLensAccount, + validateIsStaff, + async (req: Request, res: Response) => { + const { id } = req.query; + + if (!id) { + return noBody(res); + } + + try { + const impressions = await clickhouseClient.query({ + format: "JSONEachRow", + query: ` + WITH toYear(now()) AS current_year + SELECT + day, + impressions, + totalImpressions + FROM ( + SELECT + toDayOfYear(viewed) AS day, + count() AS impressions + FROM impressions + WHERE splitByString('-', publication)[1] = '${id}' + AND toYear(viewed) = current_year + GROUP BY day + ) AS dailyImpressions + CROSS JOIN ( + SELECT count() AS totalImpressions + FROM impressions + WHERE splitByString('-', publication)[1] = '${id}' + AND toYear(viewed) = current_year + ) AS total + ORDER BY day + ` + }); + + const result = await impressions.json<{ + day: number; + impressions: number; + totalImpressions: number; + }>(); + logger.info(`Account impressions fetched for ${id}`); + + return res.status(200).json({ + success: true, + totalImpressions: Number(result[0]?.totalImpressions), + yearlyImpressions: result.map((row) => ({ + day: row.day, + impressions: Number(row.impressions) + })) + }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/internal/leafwatch/stats.ts b/apps/api/src/routes/internal/leafwatch/stats.ts new file mode 100644 index 000000000000..80ffe90370c1 --- /dev/null +++ b/apps/api/src/routes/internal/leafwatch/stats.ts @@ -0,0 +1,119 @@ +import clickhouseClient from "@hey/db/clickhouseClient"; +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import validateIsStaff from "src/helpers/middlewares/validateIsStaff"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; + +export const get = [ + validateLensAccount, + validateIsStaff, + async (_: Request, res: Response) => { + try { + const queries: string[] = [ + ` + SELECT + COUNTIf(toDateTime(created) >= now() - INTERVAL 1 HOUR) AS last_1_hour, + COUNTIf(toDate(created) = today()) AS today, + COUNTIf(toDate(created) = yesterday()) AS yesterday, + COUNTIf(toDate(created) >= toMonday(now())) AS this_week, + COUNTIf(toDate(created) >= toStartOfMonth(now())) AS this_month, + COUNT(*) AS all_time + FROM events + `, + ` + SELECT + COUNTIf(toDateTime(viewed) >= now() - INTERVAL 1 HOUR) AS last_1_hour, + COUNTIf(toDate(viewed) = today()) AS today, + COUNTIf(toDate(viewed) = yesterday()) AS yesterday, + COUNTIf(toDate(viewed) >= toMonday(now())) AS this_week, + COUNTIf(toDate(viewed) >= toStartOfMonth(now())) AS this_month, + COUNT(*) AS all_time + FROM impressions + `, + ` + SELECT + toStartOfInterval(created, INTERVAL 10 MINUTE) AS timestamp, + COUNT(*) AS count + FROM events + WHERE toDate(created) = today() + GROUP BY timestamp + ORDER BY timestamp + `, + ` + SELECT + toStartOfInterval(viewed, INTERVAL 10 MINUTE) AS timestamp, + COUNT(*) AS count + FROM impressions + WHERE toDate(viewed) = today() + GROUP BY timestamp + ORDER BY timestamp + `, + ` + SELECT + CAST(created AS date) AS date, + COUNT(DISTINCT COALESCE(actor, fingerprint, CAST(ip AS String))) AS dau, + COUNT(*) AS events + FROM + events + WHERE + created >= DATE_SUB(NOW(), INTERVAL 10 DAY) + AND created < NOW() + GROUP BY CAST(created AS date) + ORDER BY CAST(created AS date) DESC + `, + ` + SELECT + CAST(viewed AS date) AS date, + COUNT(*) AS impressions + FROM impressions + WHERE + viewed >= DATE_SUB(NOW(), INTERVAL 10 DAY) + AND viewed < NOW() + GROUP BY CAST(viewed AS date) + ORDER BY CAST(viewed AS date) DESC + `, + ` + SELECT + referrer, + COUNT(DISTINCT COALESCE(actor, fingerprint, CAST(ip AS String))) AS count + FROM events + WHERE toDate(created) = today() + AND referrer IS NOT NULL + AND actor IS NOT NULL + GROUP BY referrer + ORDER BY count DESC + LIMIT 10 + ` + ]; + + // Execute all queries concurrently + const results: any = await Promise.all( + queries.map((query) => + clickhouseClient + .query({ format: "JSONEachRow", query }) + .then((rows) => rows.json()) + ) + ); + + logger.info("Fetched Leafwatch stats"); + + return res.status(200).json({ + dau: results[4].map((row: any, index: number) => ({ + date: row.date, + dau: row.dau, + events: row.events, + impressions: results[5][index].impressions + })), + events: results[0][0], + eventsToday: results[2], + impressions: results[1][0], + impressionsToday: results[3], + referrers: results[6], + success: true + }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/internal/permissions/all.ts b/apps/api/src/routes/internal/permissions/all.ts new file mode 100644 index 000000000000..59b52b206b07 --- /dev/null +++ b/apps/api/src/routes/internal/permissions/all.ts @@ -0,0 +1,25 @@ +import prisma from "@hey/db/prisma/db/client"; +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import validateIsStaff from "src/helpers/middlewares/validateIsStaff"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; + +export const get = [ + validateLensAccount, + validateIsStaff, + async (_: Request, res: Response) => { + try { + const permissions = await prisma.permission.findMany({ + include: { _count: { select: { profiles: true } } }, + orderBy: { profiles: { _count: "desc" } } + }); + + logger.info("All permissions fetched"); + + return res.status(200).json({ result: permissions, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/internal/permissions/assign.ts b/apps/api/src/routes/internal/permissions/assign.ts new file mode 100644 index 000000000000..bc36429ad462 --- /dev/null +++ b/apps/api/src/routes/internal/permissions/assign.ts @@ -0,0 +1,129 @@ +import { APP_NAME } from "@hey/data/constants"; +import { PermissionId } from "@hey/data/permissions"; +import prisma from "@hey/db/prisma/db/client"; +import { delRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import sendEmailToAccount from "src/helpers/email/sendEmailToAccount"; +import validateIsStaff from "src/helpers/middlewares/validateIsStaff"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; +import { invalidBody, noBody } from "src/helpers/responses"; +import sendSlackMessage from "src/helpers/slack"; +import { boolean, object, string } from "zod"; + +export const postUpdateTasks = async ( + accountId: string, + permissionId: string, + enabled: boolean +) => { + await delRedis(`preference:${accountId}`); + await delRedis(`account:${accountId}`); + + await sendSlackMessage({ + channel: "#permissions", + color: enabled ? "#16a34a" : "#dc2626", + text: `:hey: Permission: ${permissionId} has been ${enabled ? "enabled" : "disabled"} for ${accountId}` + }); + + if (permissionId === PermissionId.StaffPick) { + if (enabled) { + sendEmailToAccount({ + id: accountId, + subject: `Your account on ${APP_NAME} has been Staff Picked!`, + body: ` + + +

Hey Hey!

+
+

Yay! Your account on ${APP_NAME} has been Staff Picked! 🌸

+
+

Thanks,

+

${APP_NAME} team

+ + + ` + }); + } + delRedis("staff-picks"); + } + + if (permissionId === PermissionId.Verified) { + if (enabled) { + sendEmailToAccount({ + id: accountId, + subject: `Your account on ${APP_NAME} has been verified!`, + body: ` + + +

Hey Hey!

+
+

Yay! Your account on ${APP_NAME} has been verified! ✅

+ Visit your profile → +
+

Thanks,

+

${APP_NAME} team

+ + + ` + }); + } + await delRedis("verified"); + } +}; + +interface ExtensionRequest { + enabled: boolean; + id: string; + accountId: string; +} + +const validationSchema = object({ + enabled: boolean(), + id: string(), + accountId: string() +}); + +export const post = [ + validateLensAccount, + validateIsStaff, + async (req: Request, res: Response) => { + const { body } = req; + + if (!body) { + return noBody(res); + } + + const validation = validationSchema.safeParse(body); + + if (!validation.success) { + return invalidBody(res); + } + + const { enabled, id, accountId } = body as ExtensionRequest; + + try { + if (enabled) { + await prisma.profilePermission.create({ + data: { permissionId: id, profileId: accountId } + }); + + await postUpdateTasks(accountId, id, true); + logger.info(`Enabled permissions for ${accountId}`); + + return res.status(200).json({ enabled, success: true }); + } + + await prisma.profilePermission.deleteMany({ + where: { permissionId: id as string, profileId: accountId as string } + }); + + await postUpdateTasks(accountId, id, false); + logger.info(`Disabled permissions for ${accountId}`); + + return res.status(200).json({ enabled, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/internal/permissions/bulkAssign.ts b/apps/api/src/routes/internal/permissions/bulkAssign.ts new file mode 100644 index 000000000000..312f2e8c3aef --- /dev/null +++ b/apps/api/src/routes/internal/permissions/bulkAssign.ts @@ -0,0 +1,66 @@ +import prisma from "@hey/db/prisma/db/client"; +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import validateIsStaff from "src/helpers/middlewares/validateIsStaff"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; +import { invalidBody, noBody } from "src/helpers/responses"; +import { object, string } from "zod"; + +interface ExtensionRequest { + id: string; + ids: string; +} + +const validationSchema = object({ + id: string(), + ids: string().regex(/0x[\dA-Fa-f]+/g) +}); + +export const post = [ + validateLensAccount, + validateIsStaff, + async (req: Request, res: Response) => { + const { body } = req; + + if (!body) { + return noBody(res); + } + + const validation = validationSchema.safeParse(body); + + if (!validation.success) { + return invalidBody(res); + } + + const { id: permissionId, ids } = body as ExtensionRequest; + + try { + const parsedIds = JSON.parse(ids) as string[]; + + const accountPermissions = await prisma.profilePermission.findMany({ + where: { permissionId, profileId: { in: parsedIds } } + }); + + const idsToAssign = parsedIds.filter( + (profileId) => + !accountPermissions.some( + (accountPermission) => accountPermission.profileId === profileId + ) + ); + + const accountPermission = await prisma.profilePermission.createMany({ + data: idsToAssign.map((profileId) => ({ permissionId, profileId })), + skipDuplicates: true + }); + + logger.info(`Bulk assigned permissions for ${parsedIds.length} profiles`); + + return res + .status(200) + .json({ assigned: accountPermission.count, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/internal/stats/overview.ts b/apps/api/src/routes/internal/stats/overview.ts new file mode 100644 index 000000000000..459791b98bc0 --- /dev/null +++ b/apps/api/src/routes/internal/stats/overview.ts @@ -0,0 +1,53 @@ +import prisma from "@hey/db/prisma/db/client"; +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import validateIsStaff from "src/helpers/middlewares/validateIsStaff"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; + +export const get = [ + validateLensAccount, + validateIsStaff, + async (_: Request, res: Response) => { + try { + const result = await prisma.$transaction([ + prisma.list.count(), + prisma.listProfile.count(), + prisma.pinnedList.count(), + prisma.profilePermission.count(), + prisma.email.count(), + prisma.membershipNft.count(), + prisma.poll.count(), + prisma.pollOption.count(), + prisma.pollResponse.count(), + prisma.preference.count(), + prisma.profileStatus.count(), + prisma.profileTheme.count(), + prisma.tip.count() + ]); + + logger.info("Fetched overview stats"); + + return res.status(200).json({ + result: { + lists: result[0], + listProfiles: result[1], + pinnedLists: result[2], + profilePermissions: result[3], + emails: result[4], + membershipNfts: result[5], + polls: result[6], + pollOptions: result[7], + pollResponses: result[8], + preferences: result[9], + accountStatuses: result[10], + profileThemes: result[11], + tips: result[12] + }, + success: true + }); + } catch (error) { + catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/internal/tokens/create.ts b/apps/api/src/routes/internal/tokens/create.ts new file mode 100644 index 000000000000..2d25469d9774 --- /dev/null +++ b/apps/api/src/routes/internal/tokens/create.ts @@ -0,0 +1,58 @@ +import { Regex } from "@hey/data/regex"; +import prisma from "@hey/db/prisma/db/client"; +import { delRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import validateIsStaff from "src/helpers/middlewares/validateIsStaff"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; +import { invalidBody, noBody } from "src/helpers/responses"; +import { number, object, string } from "zod"; + +interface ExtensionRequest { + contractAddress: string; + decimals: number; + name: string; + symbol: string; +} + +const validationSchema = object({ + contractAddress: string().min(1).max(42).regex(Regex.ethereumAddress), + decimals: number().min(0).max(18), + name: string().min(1).max(100), + symbol: string().min(1).max(100) +}); + +export const post = [ + validateLensAccount, + validateIsStaff, + async (req: Request, res: Response) => { + const { body } = req; + + if (!body) { + return noBody(res); + } + + const validation = validationSchema.safeParse(body); + + if (!validation.success) { + return invalidBody(res); + } + + const { contractAddress, decimals, name, symbol } = + body as ExtensionRequest; + + try { + const token = await prisma.allowedToken.create({ + data: { contractAddress, decimals, name, symbol } + }); + + await delRedis("allowedTokens"); + logger.info(`Created a token ${token.id}`); + + return res.status(200).json({ result: token, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/internal/tokens/delete.ts b/apps/api/src/routes/internal/tokens/delete.ts new file mode 100644 index 000000000000..9b06f1321ec8 --- /dev/null +++ b/apps/api/src/routes/internal/tokens/delete.ts @@ -0,0 +1,47 @@ +import prisma from "@hey/db/prisma/db/client"; +import { delRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import validateIsStaff from "src/helpers/middlewares/validateIsStaff"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; +import { invalidBody, noBody } from "src/helpers/responses"; +import { object, string } from "zod"; + +interface ExtensionRequest { + id: string; +} + +const validationSchema = object({ + id: string() +}); + +export const post = [ + validateLensAccount, + validateIsStaff, + async (req: Request, res: Response) => { + const { body } = req; + + if (!body) { + return noBody(res); + } + + const validation = validationSchema.safeParse(body); + + if (!validation.success) { + return invalidBody(res); + } + + const { id } = body as ExtensionRequest; + + try { + await prisma.allowedToken.delete({ where: { id } }); + await delRedis("allowedTokens"); + logger.info(`Deleted a token ${id}`); + + return res.status(200).json({ success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/leafwatch/events.ts b/apps/api/src/routes/leafwatch/events.ts new file mode 100644 index 000000000000..941da818be07 --- /dev/null +++ b/apps/api/src/routes/leafwatch/events.ts @@ -0,0 +1,95 @@ +import { ALL_EVENTS } from "@hey/data/tracking"; +import { rPushRedis } from "@hey/db/redisClient"; +import getIp from "@hey/helpers/getIp"; +import logger from "@hey/helpers/logger"; +import parseJwt from "@hey/helpers/parseJwt"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import findEventKeyDeep from "src/helpers/leafwatch/findEventKeyDeep"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; +import { invalidBody, noBody } from "src/helpers/responses"; +import { UAParser } from "ua-parser-js"; +import { any, array, object, string } from "zod"; + +interface ExtensionRequest { + events: { + fingerprint?: string; + name: string; + properties?: string; + referrer?: string; + url: string; + }[]; +} + +const validationSchema = object({ + events: array( + object({ + fingerprint: string().nullable().optional(), + name: string().min(1), + properties: any(), + referrer: string().nullable().optional(), + url: string() + }) + ) +}); + +export const post = [ + rateLimiter({ requests: 50, within: 1 }), + async (req: Request, res: Response) => { + const { body } = req; + + if (!body) { + return noBody(res); + } + + const validation = validationSchema.safeParse(body); + + if (!validation.success) { + return invalidBody(res); + } + + const { events } = body as ExtensionRequest; + + for (const event of events) { + if (!findEventKeyDeep(ALL_EVENTS, event.name)?.length) { + return res + .status(400) + .json({ error: "Invalid event found!", success: false }); + } + } + + const userAgent = req.headers["user-agent"]; + const ip = getIp(req); + const cfIpCity = req.headers["cf-ipcity"]; + const cfIpCountry = req.headers["cf-ipcountry"]; + const idToken = req.headers["x-id-token"] as string; + + try { + const parser = new UAParser(userAgent || ""); + const ua = parser.getResult(); + + const payload = parseJwt(idToken); + + const values = events.map((event) => ({ + actor: payload.id || null, + browser: ua.browser.name || null, + city: cfIpCity || null, + country: cfIpCountry || null, + created: new Date().toISOString().slice(0, 19).replace("T", " "), + fingerprint: event.fingerprint || null, + ip: ip || null, + name: event.name, + properties: event.properties || null, + referrer: event.referrer || null, + url: event.url || null + })); + + const queue = await rPushRedis("events", JSON.stringify(values)); + logger.info(`Ingested ${values.length} events to Leafwatch`); + + return res.status(200).json({ queue, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/leafwatch/impressions.ts b/apps/api/src/routes/leafwatch/impressions.ts new file mode 100644 index 000000000000..c41dda9f2962 --- /dev/null +++ b/apps/api/src/routes/leafwatch/impressions.ts @@ -0,0 +1,48 @@ +import { rPushRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; +import { invalidBody, noBody } from "src/helpers/responses"; +import { array, object, string } from "zod"; + +interface ExtensionRequest { + ids: string[]; +} + +const validationSchema = object({ + ids: array(string()) +}); + +export const post = [ + rateLimiter({ requests: 50, within: 1 }), + async (req: Request, res: Response) => { + const { body } = req; + + if (!body) { + return noBody(res); + } + + const validation = validationSchema.safeParse(body); + + if (!validation.success) { + return invalidBody(res); + } + + const { ids } = body as ExtensionRequest; + + try { + const values = ids.map((id) => ({ + publication: id, + viewed: new Date().toISOString().slice(0, 19).replace("T", " ") + })); + + const queue = await rPushRedis("impressions", JSON.stringify(values)); + logger.info(`Ingested ${values.length} impressions to Leafwatch`); + + return res.status(200).json({ queue, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/lens/internal/stats/nft-revenue.ts b/apps/api/src/routes/lens/internal/stats/nft-revenue.ts new file mode 100644 index 000000000000..5c378ff9aa13 --- /dev/null +++ b/apps/api/src/routes/lens/internal/stats/nft-revenue.ts @@ -0,0 +1,41 @@ +import { HEY_MEMBERSHIP_NFT_POST_ID } from "@hey/data/constants"; +import lensPg from "@hey/db/lensPg"; +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import validateIsStaff from "src/helpers/middlewares/validateIsStaff"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; + +export const get = [ + validateLensAccount, + validateIsStaff, + async (_: Request, res: Response) => { + try { + const openActionModuleActed = await lensPg.query( + ` + SELECT + block_timestamp::date AS date, + COUNT(*) AS count + FROM publication.open_action_module_acted_record + WHERE + publication_id = $1 + AND block_timestamp >= NOW() - INTERVAL '30 days' + GROUP BY date + ORDER BY date; + `, + [HEY_MEMBERSHIP_NFT_POST_ID] + ); + + const result = openActionModuleActed.map((row) => ({ + date: new Date(row.date).toISOString(), + count: Number(row.count) + })); + + logger.info("[Lens] Fetched membership NFT revenue stats"); + + return res.status(200).json({ result, success: true }); + } catch (error) { + catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/lens/internal/stats/overview.ts b/apps/api/src/routes/lens/internal/stats/overview.ts new file mode 100644 index 000000000000..b122c301bff3 --- /dev/null +++ b/apps/api/src/routes/lens/internal/stats/overview.ts @@ -0,0 +1,95 @@ +import lensPg from "@hey/db/lensPg"; +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import validateIsStaff from "src/helpers/middlewares/validateIsStaff"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; + +export const get = [ + validateLensAccount, + validateIsStaff, + async (_: Request, res: Response) => { + try { + const result = await lensPg.multi( + ` + SELECT reltuples::BIGINT AS authenticationsCount + FROM pg_class + WHERE relname = 'authentication_record' AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'tracking'); + + SELECT reltuples::BIGINT AS relayUsageCount + FROM pg_class + WHERE relname = 'usage' AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'relay'); + + SELECT reltuples::BIGINT AS postsCount + FROM pg_class + WHERE relname = 'record' AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'publication'); + + SELECT reltuples::BIGINT AS profilesCount + FROM pg_class + WHERE relname = 'record' AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'profile'); + + SELECT reltuples::BIGINT AS bookmarkedPostsCount + FROM pg_class + WHERE relname = 'bookmarked_publication' AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'personalisation'); + + SELECT reltuples::BIGINT AS notInterestedPostsCount + FROM pg_class + WHERE relname = 'not_interested_publication' AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'personalisation'); + + SELECT reltuples::BIGINT AS wtfRecommendationDismissedCount + FROM pg_class + WHERE relname = 'wtf_recommendation_dismissed' AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'personalisation'); + + SELECT reltuples::BIGINT AS notificationsCount + FROM pg_class + WHERE relname = 'record' AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'notification'); + + SELECT total_count::BIGINT as momokaCount + FROM momoka.stats; + + SELECT reltuples::BIGINT AS mediaSnapshotsCount + FROM pg_class + WHERE relname = 'snapshot_mapping' AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'media'); + + SELECT reltuples::BIGINT AS qualityProfilesCount + FROM pg_class + WHERE relname = 'quality_profiles' AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'machine_learning'); + + SELECT reltuples::BIGINT AS indexedTransactionsCount + FROM pg_class + WHERE relname = 'indexed_transaction' AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'indexer'); + + SELECT reltuples::BIGINT AS hashtagsCount + FROM pg_class + WHERE relname = 'hashtag' AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'global_stats'); + + SELECT reltuples::BIGINT AS mentionsCount + FROM pg_class + WHERE relname = 'mention' AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'global_stats'); + + SELECT reltuples::BIGINT AS ensCount + FROM pg_class + WHERE relname = 'address_reverse_record' AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'ens'); + + SELECT reltuples::BIGINT AS gardenerReportsCount + FROM pg_class + WHERE relname = 'gardener_profile_record' AND relnamespace = (SELECT oid FROM pg_namespace WHERE nspname = 'custom_filters'); + ` + ); + + const flatObject = result.reduce((acc: any, item) => { + const innerItem = item[0]; + const key = Object.keys(innerItem)[0]; + const value = innerItem[key]; + acc[key] = value; + return acc; + }, {}); + + logger.info("[Lens] Fetched overview stats"); + + return res.status(200).json({ result: flatObject, success: true }); + } catch (error) { + catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/lens/internal/stats/signup-revenue.ts b/apps/api/src/routes/lens/internal/stats/signup-revenue.ts new file mode 100644 index 000000000000..0f0c03190d45 --- /dev/null +++ b/apps/api/src/routes/lens/internal/stats/signup-revenue.ts @@ -0,0 +1,41 @@ +import { HEY_LENS_SIGNUP } from "@hey/data/constants"; +import lensPg from "@hey/db/lensPg"; +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import validateIsStaff from "src/helpers/middlewares/validateIsStaff"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; + +export const get = [ + validateLensAccount, + validateIsStaff, + async (_: Request, res: Response) => { + try { + const onboardingProfile = await lensPg.query( + ` + SELECT + block_timestamp::date AS date, + COUNT(*) AS signups_count + FROM app.onboarding_profile + WHERE + onboarded_by_address = $1 + AND block_timestamp >= NOW() - INTERVAL '30 days' + GROUP BY date + ORDER BY date; + `, + [HEY_LENS_SIGNUP] + ); + + const result = onboardingProfile.map((row) => ({ + date: new Date(row.date).toISOString(), + count: Number(row.signups_count) + })); + + logger.info("[Lens] Fetched signup revenue stats"); + + return res.status(200).json({ result, success: true }); + } catch (error) { + catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/lens/rate.ts b/apps/api/src/routes/lens/rate.ts new file mode 100644 index 000000000000..cef8b8c6e2e2 --- /dev/null +++ b/apps/api/src/routes/lens/rate.ts @@ -0,0 +1,31 @@ +import { getRedis, setRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import getRates from "src/helpers/lens/getRates"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; + +export const get = [ + rateLimiter({ requests: 250, within: 1 }), + async (_: Request, res: Response) => { + try { + const cacheKey = "rates"; + const cachedData = await getRedis(cacheKey); + + if (cachedData) { + logger.info("(cached) [Lens] Fetched USD conversion rates"); + return res + .status(200) + .json({ result: JSON.parse(cachedData), success: true }); + } + + const result = await getRates(); + await setRedis(cacheKey, result, 200); + logger.info("[Lens] Fetched USD conversion rates"); + + return res.status(200).json({ result, success: true }); + } catch (error) { + catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/lists/accounts.ts b/apps/api/src/routes/lists/accounts.ts new file mode 100644 index 000000000000..11b7a8d977df --- /dev/null +++ b/apps/api/src/routes/lists/accounts.ts @@ -0,0 +1,56 @@ +import lensPg from "@hey/db/lensPg"; +import prisma from "@hey/db/prisma/db/client"; +import { getRedis, setRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; +import { noBody } from "src/helpers/responses"; + +export const get = [ + rateLimiter({ requests: 500, within: 1 }), + async (req: Request, res: Response) => { + const { id } = req.query; + + if (!id) { + return noBody(res); + } + + try { + const cacheKey = `list:accounts:${id}`; + const cachedData = await getRedis(cacheKey); + + if (cachedData) { + logger.info(`(cached) List accounts fetched for ${id}`); + return res + .status(200) + .json({ result: JSON.parse(cachedData), success: true }); + } + + const listAccounts = await prisma.listProfile.findMany({ + where: { listId: id as string } + }); + + const accountIds = listAccounts.map((account) => account.profileId); + const accountsList = accountIds.map((p) => `'${p}'`).join(","); + + const accounts = await lensPg.query( + ` + SELECT profile_id + FROM profile.record + WHERE profile_id IN (${accountsList}) + AND is_burnt = false + LIMIT 50 + ` + ); + + const result = accounts.map((account) => account.profile_id); + await setRedis(cacheKey, JSON.stringify(result)); + logger.info(`Lists accounts fetched for ${id}`); + + return res.status(200).json({ result, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/lists/add.ts b/apps/api/src/routes/lists/add.ts new file mode 100644 index 000000000000..2f13c4b1fb26 --- /dev/null +++ b/apps/api/src/routes/lists/add.ts @@ -0,0 +1,116 @@ +import { Errors } from "@hey/data/errors"; +import lensPg from "@hey/db/lensPg"; +import prisma from "@hey/db/prisma/db/client"; +import { delRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; +import parseJwt from "@hey/helpers/parseJwt"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; +import { invalidBody, noBody, notFound } from "src/helpers/responses"; +import { boolean, object, string } from "zod"; + +interface ExtensionRequest { + listId: string; + accountId: string; + add: boolean; +} + +const validationSchema = object({ + listId: string().min(1), + accountId: string().min(1), + add: boolean() +}); + +export const post = [ + validateLensAccount, + async (req: Request, res: Response) => { + const { body } = req; + + if (!body) { + return noBody(res); + } + + const validation = validationSchema.safeParse(body); + + if (!validation.success) { + return invalidBody(res); + } + + const { listId, accountId, add } = body as ExtensionRequest; + + try { + const idToken = req.headers["x-id-token"] as string; + const payload = parseJwt(idToken); + const listAccountsCacheKey = `list:accounts:${listId}`; + const listCacheKey = `list:${listId}`; + + const clearCache = async () => { + await Promise.all([ + delRedis(listAccountsCacheKey), + delRedis(listCacheKey) + ]); + }; + + const account = await lensPg.query( + ` + SELECT EXISTS ( + SELECT 1 FROM profile.record + WHERE profile_id = $1 + AND is_burnt = false + LIMIT 1 + ) AS result; + `, + [accountId] + ); + + const hasAccount = account[0]?.result; + + if (!hasAccount) { + return notFound(res); + } + + // Check if the list exists and belongs to the authenticated user and the number of accounts in the list + const [list, count] = await prisma.$transaction([ + prisma.list.findUnique({ + where: { id: listId, createdBy: payload.id } + }), + prisma.listProfile.count({ where: { listId } }) + ]); + + if (!list) { + return catchedError(res, new Error(Errors.Unauthorized), 401); + } + + if (count >= 500) { + return catchedError( + res, + new Error( + "You have reached the maximum number of profiles in a list!" + ), + 400 + ); + } + + if (add) { + const listAccount = await prisma.listProfile.create({ + data: { listId, profileId: accountId } + }); + await clearCache(); + logger.info(`Added account ${accountId} to list ${listId}`); + + return res.status(200).json({ result: listAccount, success: true }); + } + + await prisma.listProfile.delete({ + where: { profileId_listId: { profileId: accountId, listId } } + }); + await clearCache(); + logger.info(`Removed account ${accountId} from list ${listId}`); + + return res.status(200).json({ success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/lists/all.ts b/apps/api/src/routes/lists/all.ts new file mode 100644 index 000000000000..698dd27f9914 --- /dev/null +++ b/apps/api/src/routes/lists/all.ts @@ -0,0 +1,46 @@ +import prisma from "@hey/db/prisma/db/client"; +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; +import { noBody } from "src/helpers/responses"; + +export const get = [ + rateLimiter({ requests: 500, within: 1 }), + async (req: Request, res: Response) => { + const { ownerId, viewingId } = req.query; + + if (!ownerId) { + return noBody(res); + } + + try { + const list = await prisma.list.findMany({ + include: { + _count: { select: { profiles: true, pinnedList: true } }, + profiles: { where: { profileId: viewingId as string } }, + pinnedList: { where: { profileId: ownerId as string } } + }, + where: { createdBy: ownerId as string } + }); + + const result = list.map((list) => { + const { _count, profiles, pinnedList, ...rest } = list; + + return { + ...rest, + totalAccounts: _count.profiles, + totalPins: _count.pinnedList, + pinned: pinnedList.length > 0, + isAdded: profiles.length > 0 + }; + }); + + logger.info(`Lists fetched for ${ownerId}`); + + return res.status(200).json({ result, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/lists/create.ts b/apps/api/src/routes/lists/create.ts new file mode 100644 index 000000000000..e38f0a056b67 --- /dev/null +++ b/apps/api/src/routes/lists/create.ts @@ -0,0 +1,66 @@ +import prisma from "@hey/db/prisma/db/client"; +import logger from "@hey/helpers/logger"; +import parseJwt from "@hey/helpers/parseJwt"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; +import { invalidBody, noBody } from "src/helpers/responses"; +import { object, string } from "zod"; + +interface ExtensionRequest { + name: string; + description: string | null; + avatar: string | null; +} + +const validationSchema = object({ + name: string().min(1).max(100), + description: string().min(1).max(1000).optional(), + avatar: string().min(1).max(1000).optional() +}); + +export const post = [ + validateLensAccount, + async (req: Request, res: Response) => { + const { body } = req; + + if (!body) { + return noBody(res); + } + + const validation = validationSchema.safeParse(body); + + if (!validation.success) { + return invalidBody(res); + } + + const { name, description, avatar } = body as ExtensionRequest; + + try { + const idToken = req.headers["x-id-token"] as string; + const payload = parseJwt(idToken); + + const count = await prisma.list.count({ + where: { createdBy: payload.id } + }); + + if (count >= 50) { + return catchedError( + res, + new Error("You have reached the maximum number of lists!"), + 400 + ); + } + + const list = await prisma.list.create({ + data: { name, description, avatar, createdBy: payload.id } + }); + + logger.info(`Created a list ${list.id}`); + + return res.status(200).json({ result: list, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/lists/delete.ts b/apps/api/src/routes/lists/delete.ts new file mode 100644 index 000000000000..609ac795a5ba --- /dev/null +++ b/apps/api/src/routes/lists/delete.ts @@ -0,0 +1,71 @@ +import { Errors } from "@hey/data/errors"; +import prisma from "@hey/db/prisma/db/client"; +import { delRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; +import parseJwt from "@hey/helpers/parseJwt"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; +import { invalidBody, noBody } from "src/helpers/responses"; +import { object, string } from "zod"; + +interface ExtensionRequest { + id: string; +} + +const validationSchema = object({ + id: string().min(1) +}); + +// TODO: Add tests +export const post = [ + validateLensAccount, + async (req: Request, res: Response) => { + const { body } = req; + + if (!body) { + return noBody(res); + } + + const validation = validationSchema.safeParse(body); + + if (!validation.success) { + return invalidBody(res); + } + + const { id } = body as ExtensionRequest; + + try { + const idToken = req.headers["x-id-token"] as string; + const payload = parseJwt(idToken); + const listCacheKey = `list:${id}`; + const listAccountsCacheKey = `list:accounts:${id}`; + + const clearCache = async () => { + await Promise.all([ + delRedis(listCacheKey), + delRedis(listAccountsCacheKey) + ]); + }; + + // Check if the list exists and belongs to the authenticated user + const list = await prisma.list.findUnique({ + where: { id, createdBy: payload.id } + }); + + if (!list) { + return catchedError(res, new Error(Errors.Unauthorized), 401); + } + + await prisma.list.delete({ + where: { id } + }); + await clearCache(); + logger.info(`Deleted a list ${id} by ${payload.id}`); + + return res.status(200).json({ success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/lists/get.ts b/apps/api/src/routes/lists/get.ts new file mode 100644 index 000000000000..08e8fda86a94 --- /dev/null +++ b/apps/api/src/routes/lists/get.ts @@ -0,0 +1,60 @@ +import prisma from "@hey/db/prisma/db/client"; +import { getRedis, setRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; +import { noBody, notFound } from "src/helpers/responses"; + +export const get = [ + rateLimiter({ requests: 500, within: 1 }), + async (req: Request, res: Response) => { + const { id, viewingId } = req.query; + + if (!id) { + return noBody(res); + } + + try { + const cacheKey = `list:${id}`; + const cachedData = await getRedis(cacheKey); + + if (cachedData) { + logger.info(`(cached) List fetched for ${id}`); + return res + .status(200) + .json({ result: JSON.parse(cachedData), success: true }); + } + + const list = await prisma.list.findUnique({ + include: { + _count: { select: { profiles: true, pinnedList: true } }, + profiles: { take: 10, select: { profileId: true } }, + pinnedList: { where: { profileId: viewingId as string } } + }, + where: { id: id as string } + }); + + if (!list) { + return notFound(res); + } + + const { _count, profiles, pinnedList, ...rest } = list; + + const result = { + ...rest, + profiles: profiles.map((account) => account.profileId), + totalAccounts: _count.profiles, + totalPins: _count.pinnedList, + pinned: pinnedList.length > 0 + }; + + await setRedis(cacheKey, JSON.stringify(result)); + logger.info(`List fetched for ${id}`); + + return res.status(200).json({ result, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/lists/pin.ts b/apps/api/src/routes/lists/pin.ts new file mode 100644 index 000000000000..a5a1f06ea86b --- /dev/null +++ b/apps/api/src/routes/lists/pin.ts @@ -0,0 +1,72 @@ +import prisma from "@hey/db/prisma/db/client"; +import { delRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; +import parseJwt from "@hey/helpers/parseJwt"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; +import { invalidBody, noBody, notFound } from "src/helpers/responses"; +import { boolean, object, string } from "zod"; + +interface ExtensionRequest { + id: string; + pin: boolean; +} + +const validationSchema = object({ + id: string().uuid(), + pin: boolean() +}); + +export const post = [ + validateLensAccount, + async (req: Request, res: Response) => { + const { body } = req; + + if (!body) { + return noBody(res); + } + + const validation = validationSchema.safeParse(body); + + if (!validation.success) { + console.log(validation.error); + return invalidBody(res); + } + + const { id, pin } = body as ExtensionRequest; + + try { + const idToken = req.headers["x-id-token"] as string; + const payload = parseJwt(idToken); + const listCacheKey = `list:${id}`; + + // Check if the list exists + const list = await prisma.list.findUnique({ where: { id } }); + + if (!list) { + return notFound(res); + } + + if (pin) { + await prisma.pinnedList.create({ + data: { profileId: payload.id, listId: id } + }); + await delRedis(listCacheKey); + logger.info(`Pinned list ${id} for profile ${payload.id}`); + + return res.status(200).json({ success: true }); + } + + await prisma.pinnedList.delete({ + where: { profileId_listId: { profileId: payload.id, listId: id } } + }); + await delRedis(listCacheKey); + logger.info(`Unpinned list ${id} for profile ${payload.id}`); + + return res.status(200).json({ success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/lists/pinned.ts b/apps/api/src/routes/lists/pinned.ts new file mode 100644 index 000000000000..1026c7a27825 --- /dev/null +++ b/apps/api/src/routes/lists/pinned.ts @@ -0,0 +1,44 @@ +import prisma from "@hey/db/prisma/db/client"; +import logger from "@hey/helpers/logger"; +import parseJwt from "@hey/helpers/parseJwt"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; + +export const get = [ + rateLimiter({ requests: 500, within: 1 }), + validateLensAccount, + async (req: Request, res: Response) => { + try { + const idToken = req.headers["x-id-token"] as string; + const payload = parseJwt(idToken); + + const list = await prisma.list.findMany({ + include: { + _count: { select: { profiles: true, pinnedList: true } }, + pinnedList: { where: { profileId: payload.id } } + }, + where: { pinnedList: { some: { profileId: payload.id } } } + }); + + logger.info(`Pinned lists fetched for ${payload.id}`); + + return res.status(200).json({ + result: list.map((list) => { + const { _count, pinnedList, ...rest } = list; + + return { + ...rest, + totalAccounts: _count.profiles, + totalPins: _count.pinnedList, + pinned: pinnedList.length > 0 + }; + }), + success: true + }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/lists/posts.ts b/apps/api/src/routes/lists/posts.ts new file mode 100644 index 000000000000..13a058d77f46 --- /dev/null +++ b/apps/api/src/routes/lists/posts.ts @@ -0,0 +1,60 @@ +import lensPg from "@hey/db/lensPg"; +import prisma from "@hey/db/prisma/db/client"; +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; +import { noBody } from "src/helpers/responses"; + +export const get = [ + rateLimiter({ requests: 500, within: 1 }), + async (req: Request, res: Response) => { + const { id, page = 1, size = 50 } = req.query; + + if (!id) { + return noBody(res); + } + + try { + const list = await prisma.list.findUnique({ + select: { profiles: { select: { profileId: true } } }, + where: { id: id as string } + }); + + if (!list?.profiles.length) { + return res.status(200).json({ result: [], success: true }); + } + + const accounts = list.profiles.map((account) => account.profileId); + const accountsList = accounts.map((p) => `'${p}'`).join(","); + + // Calculate the offset for pagination + const offset = + (Number.parseInt(page as string) - 1) * Number.parseInt(size as string); + + const posts = await lensPg.query( + ` + SELECT publication_id AS id + FROM publication_view + WHERE profile_id IN (${accountsList}) + AND publication_type IN ('POST', 'MIRROR') + AND is_hidden = false + ORDER BY timestamp DESC + LIMIT $1 OFFSET $2 + `, + [size, offset] + ); + + logger.info(`List posts fetched for ${id}, page ${page}, size ${size}`); + + return res.status(200).json({ + result: posts.map((row) => row.id), + size, + offset, + success: true + }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/lists/update.ts b/apps/api/src/routes/lists/update.ts new file mode 100644 index 000000000000..c93001f78613 --- /dev/null +++ b/apps/api/src/routes/lists/update.ts @@ -0,0 +1,73 @@ +import { Errors } from "@hey/data/errors"; +import prisma from "@hey/db/prisma/db/client"; +import { delRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; +import parseJwt from "@hey/helpers/parseJwt"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; +import { invalidBody, noBody } from "src/helpers/responses"; +import { object, string } from "zod"; + +interface ExtensionRequest { + id: string; + name?: string; + description?: string | null; + avatar?: string | null; +} + +const validationSchema = object({ + id: string().uuid(), + name: string().min(1).max(100).optional(), + description: string().min(1).max(1000).optional(), + avatar: string().min(1).max(1000).optional() +}); + +export const post = [ + validateLensAccount, + async (req: Request, res: Response) => { + const { body } = req; + + if (!body) { + return noBody(res); + } + + const validation = validationSchema.safeParse(body); + + if (!validation.success) { + console.log(validation.error); + return invalidBody(res); + } + + const { id, name, description, avatar } = body as ExtensionRequest; + + try { + const idToken = req.headers["x-id-token"] as string; + const payload = parseJwt(idToken); + const listCacheKey = `list:${id}`; + + // Check if the list exists and belongs to the authenticated user + const list = await prisma.list.findUnique({ + where: { id, createdBy: payload.id } + }); + + if (!list) { + return catchedError(res, new Error(Errors.Unauthorized), 401); + } + + const data = { + name: name ?? list.name, + description: description ?? list.description, + avatar: avatar ?? list.avatar + }; + + const updatedList = await prisma.list.update({ where: { id }, data }); + await delRedis(listCacheKey); + logger.info(`Updated a list ${updatedList.id}`); + + return res.status(200).json({ result: updatedList, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/live/create.ts b/apps/api/src/routes/live/create.ts new file mode 100644 index 000000000000..9fe4a87aa785 --- /dev/null +++ b/apps/api/src/routes/live/create.ts @@ -0,0 +1,77 @@ +import { LIVEPEER_KEY } from "@hey/data/constants"; +import logger from "@hey/helpers/logger"; +import parseJwt from "@hey/helpers/parseJwt"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; +import { invalidBody, noBody } from "src/helpers/responses"; +import { v4 as uuid } from "uuid"; +import { boolean, object } from "zod"; + +interface ExtensionRequest { + record: boolean; +} + +const validationSchema = object({ + record: boolean() +}); + +export const post = [ + rateLimiter({ requests: 10, within: 1 }), + validateLensAccount, + async (req: Request, res: Response) => { + const { body } = req; + + if (!body) { + return noBody(res); + } + + const validation = validationSchema.safeParse(body); + + if (!validation.success) { + return invalidBody(res); + } + + const { record } = body as ExtensionRequest; + + try { + const idToken = req.headers["x-id-token"] as string; + const payload = parseJwt(idToken); + const response = await fetch("https://livepeer.studio/api/stream", { + body: JSON.stringify({ + name: `${payload.id}-${uuid()}`, + profiles: [ + { + bitrate: 3000000, + fps: 0, + height: 720, + name: "720p0", + width: 1280 + }, + { + bitrate: 6000000, + fps: 0, + height: 1080, + name: "1080p0", + width: 1920 + } + ], + record + }), + headers: { + Authorization: `Bearer ${LIVEPEER_KEY}`, + "content-type": "application/json" + }, + method: "POST" + }); + + const result = await response.json(); + logger.info(`Created stream live stream by ${payload.id}`); + + return res.status(200).json({ result, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/meta.ts b/apps/api/src/routes/meta.ts new file mode 100644 index 000000000000..e548ccc73dde --- /dev/null +++ b/apps/api/src/routes/meta.ts @@ -0,0 +1,96 @@ +import { UNLEASH_API_TOKEN } from "@hey/data/constants"; +import clickhouseClient from "@hey/db/clickhouseClient"; +import lensPg from "@hey/db/lensPg"; +import prisma from "@hey/db/prisma/db/client"; +import { getRedis } from "@hey/db/redisClient"; +import axios from "axios"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { UNLEASH_API_URL } from "src/helpers/constants"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; + +const measureQueryTime = async ( + queryFunction: () => Promise +): Promise<[any, bigint]> => { + const startTime = process.hrtime.bigint(); + const result = await queryFunction(); + const endTime = process.hrtime.bigint(); + return [result, endTime - startTime]; +}; + +export const get = [ + rateLimiter({ requests: 50, within: 1 }), + async (_: Request, res: Response) => { + try { + // Prepare promises with timings embedded + const heyPromise = measureQueryTime( + () => prisma.$queryRaw`SELECT 1 as count;` + ); + const lensPromise = measureQueryTime(() => + lensPg.query("SELECT 1 as count;") + ); + const redisPromise = measureQueryTime(() => getRedis("ping")); + const clickhousePromise = measureQueryTime(() => + clickhouseClient.query({ + format: "JSONEachRow", + query: "SELECT 1 as count;" + }) + ); + const unleashPromise = measureQueryTime(() => + axios.get(UNLEASH_API_URL, { + headers: { Authorization: UNLEASH_API_TOKEN } + }) + ); + + // Execute all promises simultaneously + const [ + heyResult, + lensResult, + redisResult, + clickhouseResult, + unleashResult + ] = await Promise.all([ + heyPromise, + lensPromise, + redisPromise, + clickhousePromise, + unleashPromise + ]); + + // Check responses + const [hey, heyTime] = heyResult; + const [lens, lensTime] = lensResult; + const [redis, redisTime] = redisResult; + const [clickhouseRows, clickhouseTime] = clickhouseResult; + const [unleash, unleashTime] = unleashResult; + + if ( + Number(hey[0].count) !== 1 || + Number(lens[0].count) !== 1 || + redis.toString() !== "pong" || + !clickhouseRows.json || + unleash.data.toggles.length === 0 + ) { + return res.status(500).json({ success: false }); + } + + // Format response times in milliseconds and return + return res.status(200).json({ + meta: { + deployment: process.env.RAILWAY_DEPLOYMENT_ID || "unknown", + replica: process.env.RAILWAY_REPLICA_ID || "unknown", + snapshot: process.env.RAILWAY_SNAPSHOT_ID || "unknown" + }, + responseTimes: { + clickhouse: `${Number(clickhouseTime / BigInt(1000000))}ms`, + hey: `${Number(heyTime / BigInt(1000000))}ms`, + lens: `${Number(lensTime / BigInt(1000000))}ms`, + redis: `${Number(redisTime / BigInt(1000000))}ms`, + unleash: `${Number(unleashTime / BigInt(1000000))}ms` + } + }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/metadata/index.ts b/apps/api/src/routes/metadata/index.ts new file mode 100644 index 000000000000..a2df1cad06fc --- /dev/null +++ b/apps/api/src/routes/metadata/index.ts @@ -0,0 +1,54 @@ +import { PutObjectCommand, S3 } from "@aws-sdk/client-s3"; +import { METADATA_ENDPOINT } from "@hey/data/constants"; +import logger from "@hey/helpers/logger"; +import { signMetadata } from "@lens-protocol/metadata"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; +import { noBody } from "src/helpers/responses"; +import { v4 as uuid } from "uuid"; +import { privateKeyToAccount } from "viem/accounts"; + +export const post = [ + rateLimiter({ requests: 30, within: 1 }), + async (req: Request, res: Response) => { + const { body } = req; + + if (!body) { + return noBody(res); + } + + try { + const account = privateKeyToAccount(`0x${process.env.PRIVATE_KEY}`); + const signed = await signMetadata(body, (message) => + account.signMessage({ message }) + ); + + const accessKeyId = process.env.AWS_ACCESS_KEY_ID; + const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY; + + const s3Client = new S3({ + credentials: { accessKeyId, secretAccessKey }, + maxAttempts: 5, + region: "eu-west-2" + }); + + const key = uuid(); + const uploadParams = { + Bucket: "hey-metadata", + Key: key, + Body: JSON.stringify(signed), + ContentType: "application/json" + }; + + const command = new PutObjectCommand(uploadParams); + await s3Client.send(command); + + logger.info(`Uploaded metadata to S3: ${METADATA_ENDPOINT}/${key}`); + + return res.status(200).json({ id: key, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/misc/verified.ts b/apps/api/src/routes/misc/verified.ts new file mode 100644 index 000000000000..3db86a1b445c --- /dev/null +++ b/apps/api/src/routes/misc/verified.ts @@ -0,0 +1,42 @@ +import { PermissionId } from "@hey/data/permissions"; +import prisma from "@hey/db/prisma/db/client"; +import { getRedis, setRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { CACHE_AGE_30_MINS } from "src/helpers/constants"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; + +export const get = [ + rateLimiter({ requests: 250, within: 1 }), + async (_: Request, res: Response) => { + try { + const cacheKey = "verified"; + const cachedData = await getRedis(cacheKey); + + if (cachedData) { + logger.info("(cached) Verified accounts fetched"); + return res + .status(200) + .setHeader("Cache-Control", CACHE_AGE_30_MINS) + .json({ result: JSON.parse(cachedData), success: true }); + } + + const accountPermission = await prisma.profilePermission.findMany({ + select: { profileId: true }, + where: { enabled: true, permissionId: PermissionId.Verified } + }); + + const result = accountPermission.map(({ profileId }) => profileId); + await setRedis(cacheKey, result); + logger.info("Verified accounts fetched"); + + return res + .status(200) + .setHeader("Cache-Control", CACHE_AGE_30_MINS) + .json({ result, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/oembed/index.ts b/apps/api/src/routes/oembed/index.ts new file mode 100644 index 000000000000..af44db265a6b --- /dev/null +++ b/apps/api/src/routes/oembed/index.ts @@ -0,0 +1,54 @@ +import { getRedis, setRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; +import sha256 from "@hey/helpers/sha256"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { CACHE_AGE_1_DAY } from "src/helpers/constants"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; +import getMetadata from "src/helpers/oembed/getMetadata"; +import { noBody } from "src/helpers/responses"; + +export const get = [ + rateLimiter({ requests: 500, within: 1 }), + async (req: Request, res: Response) => { + const { url } = req.query; + + if (!url) { + return noBody(res); + } + + try { + const cacheKey = `oembed:${sha256(url as string).slice(0, 10)}`; + const cachedData = await getRedis(cacheKey); + + if (cachedData) { + logger.info(`(cached) Oembed generated for ${url}`); + return res + .status(200) + .setHeader("Cache-Control", CACHE_AGE_1_DAY) + .json({ oembed: JSON.parse(cachedData), success: true }); + } + + const oembed = await getMetadata(url as string); + + if (!oembed) { + return res.status(200).json({ oembed: null, success: false }); + } + + const skipCache = oembed.frame !== null; + + if (!skipCache) { + await setRedis(cacheKey, oembed); + } + + logger.info(`Oembed generated for ${url}`); + + return res + .status(200) + .setHeader("Cache-Control", skipCache ? "no-cache" : CACHE_AGE_1_DAY) + .json({ oembed, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/personal/swapToMatic.ts b/apps/api/src/routes/personal/swapToMatic.ts new file mode 100644 index 000000000000..74cfd3bfa739 --- /dev/null +++ b/apps/api/src/routes/personal/swapToMatic.ts @@ -0,0 +1,82 @@ +import { Errors } from "@hey/data/errors"; +import { POLYGON_RPCS } from "@hey/data/rpcs"; +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import validateSecret from "src/helpers/middlewares/validateSecret"; +import { invalidBody, noBody } from "src/helpers/responses"; +import type { Address } from "viem"; +import { http, createWalletClient } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { polygon } from "viem/chains"; +import { object, string } from "zod"; + +const ABI = [ + { + constant: false, + inputs: [{ name: "wad", type: "uint256" }], + name: "withdraw", + outputs: [], + payable: false, + stateMutability: "nonpayable", + type: "function" + } +]; + +interface ExtensionRequest { + amount: string; +} + +const validationSchema = object({ + amount: string() +}); + +export const post = [ + validateSecret, + async (req: Request, res: Response) => { + const { body } = req; + + if (!body) { + return noBody(res); + } + + const validation = validationSchema.safeParse(body); + + if (!validation.success) { + return invalidBody(res); + } + + const { amount } = body as ExtensionRequest; + + if (!process.env.ADMIN_PRIVATE_KEY) { + return res + .status(400) + .json({ error: Errors.InvalidEnvironmentVariable, success: false }); + } + + try { + const account = privateKeyToAccount( + process.env.ADMIN_PRIVATE_KEY as Address + ); + const client = createWalletClient({ + account, + chain: polygon, + transport: http(POLYGON_RPCS[0]) + }); + + const bigintAmount = BigInt(amount); + const hash = await client.writeContract({ + abi: ABI, + address: "0x0d500b1d8e8ef31e21c99d1db9a6444d3adf1270", + args: [bigintAmount], + functionName: "withdraw" + }); + + logger.info(`Swapped ${amount} to MATIC by ${account.address}`); + + return res.status(200).json({ hash, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/personal/withdrawRewards.ts b/apps/api/src/routes/personal/withdrawRewards.ts new file mode 100644 index 000000000000..1c21671b81d6 --- /dev/null +++ b/apps/api/src/routes/personal/withdrawRewards.ts @@ -0,0 +1,82 @@ +import { Errors } from "@hey/data/errors"; +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import validateSecret from "src/helpers/middlewares/validateSecret"; +import { invalidBody, noBody } from "src/helpers/responses"; +import type { Address } from "viem"; +import { http, createWalletClient } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { zora } from "viem/chains"; +import { object, string } from "zod"; + +const ABI = [ + { + inputs: [ + { internalType: "address", name: "to", type: "address" }, + { internalType: "uint256", name: "amount", type: "uint256" } + ], + name: "withdraw", + outputs: [], + stateMutability: "nonpayable", + type: "function" + } +]; + +interface ExtensionRequest { + amount: string; +} + +const validationSchema = object({ + amount: string() +}); + +export const post = [ + validateSecret, + async (req: Request, res: Response) => { + const { body } = req; + + if (!body) { + return noBody(res); + } + + const validation = validationSchema.safeParse(body); + + if (!validation.success) { + return invalidBody(res); + } + + const { amount } = body as ExtensionRequest; + + if (!process.env.ADMIN_PRIVATE_KEY) { + return res + .status(400) + .json({ error: Errors.InvalidEnvironmentVariable, success: false }); + } + + try { + const account = privateKeyToAccount( + process.env.ADMIN_PRIVATE_KEY as Address + ); + const client = createWalletClient({ + account, + chain: zora, + transport: http("https://rpc.zora.energy") + }); + + const bigintAmount = BigInt(amount); + const hash = await client.writeContract({ + abi: ABI, + address: "0x7777777f279eba3d3ad8f4e708545291a6fdba8b", + args: ["0x03Ba34f6Ea1496fa316873CF8350A3f7eaD317EF", bigintAmount], + functionName: "withdraw" + }); + + logger.info(`Withrawn ${amount} to ${account.address} from Zora`); + + return res.status(200).json({ hash, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/polls/act.ts b/apps/api/src/routes/polls/act.ts new file mode 100644 index 000000000000..5e182603de89 --- /dev/null +++ b/apps/api/src/routes/polls/act.ts @@ -0,0 +1,79 @@ +import prisma from "@hey/db/prisma/db/client"; +import { delRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; +import parseJwt from "@hey/helpers/parseJwt"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; +import { invalidBody, noBody } from "src/helpers/responses"; +import { object, string } from "zod"; + +interface ExtensionRequest { + option: string; + poll: string; +} + +const validationSchema = object({ + option: string().uuid(), + poll: string().uuid() +}); + +export const post = [ + rateLimiter({ requests: 100, within: 1 }), + validateLensAccount, + async (req: Request, res: Response) => { + const { body } = req; + + if (!body) { + return noBody(res); + } + + const validation = validationSchema.safeParse(body); + + if (!validation.success) { + return invalidBody(res); + } + + const { option, poll } = body as ExtensionRequest; + + try { + const idToken = req.headers["x-id-token"] as string; + const payload = parseJwt(idToken); + + // Begin: Check if the poll expired + const expired = await prisma.poll.findUnique({ + select: { endsAt: true }, + where: { endsAt: { lt: new Date() }, id: poll as string } + }); + + if (expired) { + return res.status(400).json({ error: "Poll expired.", success: false }); + } + // End: Check if the poll expired + + // Begin: Check if the poll exists and delete the existing response + const existingPollResponse = await prisma.pollResponse.findFirst({ + where: { option: { pollId: poll as string }, profileId: payload.id } + }); + + if (existingPollResponse?.id) { + await prisma.pollResponse.delete({ + where: { id: existingPollResponse.id } + }); + } + // End: Check if the poll exists and delete the existing response + + const pollResponse = await prisma.pollResponse.create({ + data: { optionId: option, profileId: payload.id } + }); + + await delRedis(`poll:${poll}`); + logger.info(`Responded to a poll ${option}:${pollResponse.id}`); + + return res.status(200).json({ id: pollResponse.id, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/polls/create.ts b/apps/api/src/routes/polls/create.ts new file mode 100644 index 000000000000..ff483e1e8aca --- /dev/null +++ b/apps/api/src/routes/polls/create.ts @@ -0,0 +1,66 @@ +import prisma from "@hey/db/prisma/db/client"; +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; +import { invalidBody, noBody } from "src/helpers/responses"; +import { array, number, object, string } from "zod"; + +interface ExtensionRequest { + length: number; + options: string[]; +} + +const validationSchema = object({ + length: number(), + options: array(string()) +}); + +export const post = [ + rateLimiter({ requests: 30, within: 1 }), + validateLensAccount, + async (req: Request, res: Response) => { + const { body } = req; + + if (!body) { + return noBody(res); + } + + const validation = validationSchema.safeParse(body); + + if (!validation.success) { + return invalidBody(res); + } + + const { length, options } = body as ExtensionRequest; + + if (length < 1 || length > 30) { + return res.status(400).json({ + error: "Poll length should be between 1 and 30 days.", + success: false + }); + } + + try { + const poll = await prisma.poll.create({ + data: { + endsAt: new Date(Date.now() + length * 24 * 60 * 60 * 1000), + options: { + createMany: { + data: options.map((option, index) => ({ index, option })), + skipDuplicates: true + } + } + }, + select: { createdAt: true, endsAt: true, id: true, options: true } + }); + + logger.info(`Created a poll ${poll.id}`); + + return res.status(200).json({ result: poll, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/polls/get.ts b/apps/api/src/routes/polls/get.ts new file mode 100644 index 000000000000..9ffe9501c769 --- /dev/null +++ b/apps/api/src/routes/polls/get.ts @@ -0,0 +1,85 @@ +import prisma from "@hey/db/prisma/db/client"; +import { getRedis, setRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; +import parseJwt from "@hey/helpers/parseJwt"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; +import { noBody, notFound } from "src/helpers/responses"; + +export const get = [ + rateLimiter({ requests: 500, within: 1 }), + async (req: Request, res: Response) => { + const { id } = req.query; + + if (!id) { + return noBody(res); + } + + try { + const cacheKey = `poll:${id}`; + const cachedData = await getRedis(cacheKey); + + if (cachedData) { + logger.info("(cached) Poll fetched"); + return res + .status(200) + .json({ result: JSON.parse(cachedData), success: true }); + } + + const idToken = req.headers["x-id-token"] as string; + const payload = parseJwt(idToken); + + const poll = await prisma.poll.findUnique({ + select: { + endsAt: true, + id: true, + options: { + orderBy: { index: "asc" }, + select: { + _count: { select: { responses: true } }, + id: true, + option: true, + responses: { + select: { id: true }, + where: { profileId: payload.id } + } + } + } + }, + where: { id: id as string } + }); + + if (!poll) { + return notFound(res); + } + + const totalResponses = poll.options.reduce( + (acc, option) => acc + option._count.responses, + 0 + ); + + const result = { + endsAt: poll.endsAt, + id: poll.id, + options: poll.options.map((option) => ({ + id: option.id, + option: option.option, + percentage: + totalResponses > 0 + ? (option._count.responses / totalResponses) * 100 + : 0, + responses: option._count.responses, + voted: option.responses.length > 0 + })) + }; + + await setRedis(cacheKey, result); + logger.info("Poll fetched"); + + return res.status(200).json({ result, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/preferences/get.ts b/apps/api/src/routes/preferences/get.ts new file mode 100644 index 000000000000..eb4268a0c85a --- /dev/null +++ b/apps/api/src/routes/preferences/get.ts @@ -0,0 +1,76 @@ +import prisma from "@hey/db/prisma/db/client"; +import { getRedis, setRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; +import parseJwt from "@hey/helpers/parseJwt"; +import type { AccountTheme, Preferences } from "@hey/types/hey"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; +import { noBody } from "src/helpers/responses"; + +export const get = [ + rateLimiter({ requests: 100, within: 1 }), + validateLensAccount, + async (req: Request, res: Response) => { + try { + const idToken = req.headers["x-id-token"] as string; + const payload = parseJwt(idToken); + const { id } = payload; + + if (!id) { + return noBody(res); + } + + const cacheKey = `preference:${id}`; + const cachedData = await getRedis(cacheKey); + + if (cachedData) { + logger.info(`(cached) Account preferences fetched for ${id}`); + return res + .status(200) + .json({ result: JSON.parse(cachedData), success: true }); + } + + const [preference, permissions, email, membershipNft, theme, mutedWords] = + await prisma.$transaction([ + prisma.preference.findUnique({ where: { id: id as string } }), + prisma.profilePermission.findMany({ + include: { permission: { select: { key: true } } }, + where: { enabled: true, profileId: id as string } + }), + prisma.email.findUnique({ where: { id: id as string } }), + prisma.membershipNft.findUnique({ where: { id: id as string } }), + prisma.profileTheme.findUnique({ where: { id: id as string } }), + prisma.mutedWord.findMany({ where: { profileId: id as string } }) + ]); + + const response: Preferences = { + appIcon: preference?.appIcon || 0, + email: email?.email || null, + emailVerified: Boolean(email?.verified), + hasDismissedOrMintedMembershipNft: Boolean( + membershipNft?.dismissedOrMinted + ), + highSignalNotificationFilter: Boolean( + preference?.highSignalNotificationFilter + ), + developerMode: Boolean(preference?.developerMode), + theme: (theme as AccountTheme) || null, + permissions: permissions.map(({ permission }) => permission.key), + mutedWords: mutedWords.map(({ id, word, expiresAt }) => ({ + id, + word, + expiresAt + })) + }; + + await setRedis(cacheKey, response); + logger.info(`Account preferences fetched for ${id}`); + + return res.status(200).json({ result: response, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/preferences/mute.ts b/apps/api/src/routes/preferences/mute.ts new file mode 100644 index 000000000000..446e8bbf5921 --- /dev/null +++ b/apps/api/src/routes/preferences/mute.ts @@ -0,0 +1,57 @@ +import prisma from "@hey/db/prisma/db/client"; +import { delRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; +import parseJwt from "@hey/helpers/parseJwt"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; +import { invalidBody, noBody } from "src/helpers/responses"; +import { object, string } from "zod"; + +interface ExtensionRequest { + word: string; + expiresAt: Date | null; +} + +const validationSchema = object({ + word: string(), + expiresAt: string().nullable() +}); + +// TODO: Add tests +export const post = [ + rateLimiter({ requests: 50, within: 1 }), + validateLensAccount, + async (req: Request, res: Response) => { + const { body } = req; + + if (!body) { + return noBody(res); + } + + const validation = validationSchema.safeParse(body); + + if (!validation.success) { + return invalidBody(res); + } + + const { word, expiresAt } = body as ExtensionRequest; + + try { + const idToken = req.headers["x-id-token"] as string; + const payload = parseJwt(idToken); + + const mutedWord = await prisma.mutedWord.create({ + data: { word, expiresAt, profileId: payload.id } + }); + + await delRedis(`preference:${payload.id}`); + logger.info(`Muted a word by ${payload.id}`); + + return res.status(200).json({ result: mutedWord, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/preferences/theme/reset.ts b/apps/api/src/routes/preferences/theme/reset.ts new file mode 100644 index 000000000000..adeabc6e213f --- /dev/null +++ b/apps/api/src/routes/preferences/theme/reset.ts @@ -0,0 +1,30 @@ +import prisma from "@hey/db/prisma/db/client"; +import { delRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; +import parseJwt from "@hey/helpers/parseJwt"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; + +export const post = [ + rateLimiter({ requests: 50, within: 1 }), + validateLensAccount, + async (req: Request, res: Response) => { + try { + const idToken = req.headers["x-id-token"] as string; + const payload = parseJwt(idToken); + + const profileTheme = await prisma.profileTheme.deleteMany({ + where: { id: payload.id } + }); + + await delRedis(`preference:${payload.id}`); + logger.info(`Reset profile theme for ${payload.id}`); + + return res.status(200).json({ result: profileTheme, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/preferences/theme/update.ts b/apps/api/src/routes/preferences/theme/update.ts new file mode 100644 index 000000000000..0b2d8f6f0b5e --- /dev/null +++ b/apps/api/src/routes/preferences/theme/update.ts @@ -0,0 +1,57 @@ +import prisma from "@hey/db/prisma/db/client"; +import { delRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; +import parseJwt from "@hey/helpers/parseJwt"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; +import { invalidBody, noBody } from "src/helpers/responses"; +import { object, string } from "zod"; + +interface ExtensionRequest { + fontStyle: string | null; +} + +const validationSchema = object({ + fontStyle: string().optional() +}); + +export const post = [ + rateLimiter({ requests: 50, within: 1 }), + validateLensAccount, + async (req: Request, res: Response) => { + const { body } = req; + + if (!body) { + return noBody(res); + } + + const validation = validationSchema.safeParse(body); + + if (!validation.success) { + return invalidBody(res); + } + + const { fontStyle } = body as ExtensionRequest; + + try { + const idToken = req.headers["x-id-token"] as string; + const payload = parseJwt(idToken); + const dbPayload = { fontStyle }; + + const profileTheme = await prisma.profileTheme.upsert({ + create: { id: payload.id, ...dbPayload }, + update: dbPayload, + where: { id: payload.id } + }); + + await delRedis(`preference:${payload.id}`); + logger.info(`Updated profile theme for ${payload.id}`); + + return res.status(200).json({ result: profileTheme, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/preferences/unmute.ts b/apps/api/src/routes/preferences/unmute.ts new file mode 100644 index 000000000000..950d585da375 --- /dev/null +++ b/apps/api/src/routes/preferences/unmute.ts @@ -0,0 +1,55 @@ +import prisma from "@hey/db/prisma/db/client"; +import { delRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; +import parseJwt from "@hey/helpers/parseJwt"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; +import { invalidBody, noBody } from "src/helpers/responses"; +import { object, string } from "zod"; + +interface ExtensionRequest { + id: string; +} + +const validationSchema = object({ + id: string().uuid() +}); + +// TODO: Add tests +export const post = [ + rateLimiter({ requests: 50, within: 1 }), + validateLensAccount, + async (req: Request, res: Response) => { + const { body } = req; + + if (!body) { + return noBody(res); + } + + const validation = validationSchema.safeParse(body); + + if (!validation.success) { + return invalidBody(res); + } + + const { id } = body as ExtensionRequest; + + try { + const idToken = req.headers["x-id-token"] as string; + const payload = parseJwt(idToken); + + const mutedWord = await prisma.mutedWord.delete({ + where: { id, profileId: payload.id } + }); + + await delRedis(`preference:${payload.id}`); + logger.info(`Unmuted a word by ${payload.id}`); + + return res.status(200).json({ result: mutedWord, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/preferences/update.ts b/apps/api/src/routes/preferences/update.ts new file mode 100644 index 000000000000..e5d90a6047b4 --- /dev/null +++ b/apps/api/src/routes/preferences/update.ts @@ -0,0 +1,64 @@ +import prisma from "@hey/db/prisma/db/client"; +import { delRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; +import parseJwt from "@hey/helpers/parseJwt"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; +import { invalidBody, noBody } from "src/helpers/responses"; +import { boolean, number, object, string } from "zod"; + +interface ExtensionRequest { + id?: string; + appIcon?: number; + highSignalNotificationFilter?: boolean; + developerMode?: boolean; +} + +const validationSchema = object({ + id: string().optional(), + appIcon: number().optional(), + highSignalNotificationFilter: boolean().optional(), + developerMode: boolean().optional() +}); + +export const post = [ + rateLimiter({ requests: 50, within: 1 }), + validateLensAccount, + async (req: Request, res: Response) => { + const { body } = req; + + if (!body) { + return noBody(res); + } + + const validation = validationSchema.safeParse(body); + + if (!validation.success) { + return invalidBody(res); + } + + const { appIcon, highSignalNotificationFilter, developerMode } = + body as ExtensionRequest; + + try { + const idToken = req.headers["x-id-token"] as string; + const payload = parseJwt(idToken); + + const data = { appIcon, highSignalNotificationFilter, developerMode }; + const preference = await prisma.preference.upsert({ + create: { ...data, id: payload.id }, + update: data, + where: { id: payload.id } + }); + + await delRedis(`preference:${payload.id}`); + logger.info(`Updated preferences for ${payload.id}`); + + return res.status(200).json({ result: preference, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/preferences/updateNftStatus.ts b/apps/api/src/routes/preferences/updateNftStatus.ts new file mode 100644 index 000000000000..af1fa6cbd276 --- /dev/null +++ b/apps/api/src/routes/preferences/updateNftStatus.ts @@ -0,0 +1,29 @@ +import prisma from "@hey/db/prisma/db/client"; +import logger from "@hey/helpers/logger"; +import parseJwt from "@hey/helpers/parseJwt"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; + +export const post = [ + rateLimiter({ requests: 50, within: 1 }), + validateLensAccount, + async (req: Request, res: Response) => { + try { + const idToken = req.headers["x-id-token"] as string; + const payload = parseJwt(idToken); + + const membershipNft = await prisma.membershipNft.upsert({ + create: { dismissedOrMinted: true, id: payload.id }, + update: { dismissedOrMinted: true }, + where: { id: payload.id } + }); + logger.info(`Updated membership nft status for ${payload.id}`); + + return res.status(200).json({ result: membershipNft, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/sitemap/accounts.xml.ts b/apps/api/src/routes/sitemap/accounts.xml.ts new file mode 100644 index 000000000000..6f9047402168 --- /dev/null +++ b/apps/api/src/routes/sitemap/accounts.xml.ts @@ -0,0 +1,52 @@ +import lensPg from "@hey/db/lensPg"; +import { getRedis, setRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { CACHE_AGE_1_DAY, SITEMAP_BATCH_SIZE } from "src/helpers/constants"; +import { buildSitemapXml } from "src/helpers/sitemap/buildSitemap"; + +export const get = async (req: Request, res: Response) => { + const userAgent = req.headers["user-agent"]; + const redisKey = "sitemap:accounts:total"; + + try { + const cachedData = await getRedis(redisKey); + let totalHandles: number; + + if (cachedData) { + totalHandles = Number(cachedData); + logger.info(`[Lens] Fetched totalHandles from Redis: ${totalHandles}`); + } else { + const handles = await lensPg.query(` + SELECT COUNT(*) AS count + FROM namespace.handle h + JOIN namespace.handle_link hl ON h.handle_id = hl.handle_id + JOIN profile.record p ON hl.token_id = p.profile_id + WHERE p.is_burnt = false; + `); + + totalHandles = Number(handles[0]?.count) || 0; + await setRedis(redisKey, totalHandles); + logger.info(`[Lens] Fetched totalHandles from DB: ${totalHandles}`); + } + + const totalBatches = Math.ceil(totalHandles / SITEMAP_BATCH_SIZE); + const entries = Array.from({ length: totalBatches }, (_, index) => ({ + loc: `https://api.hey.xyz/sitemap/accounts/${index + 1}.xml` + })); + const xml = buildSitemapXml(entries); + + logger.info( + `[Lens] Fetched all accounts sitemap index having ${totalBatches} batches from user-agent: ${userAgent}` + ); + + return res + .status(200) + .setHeader("Content-Type", "text/xml") + .setHeader("Cache-Control", CACHE_AGE_1_DAY) + .send(xml); + } catch (error) { + return catchedError(res, error); + } +}; diff --git a/apps/api/src/routes/sitemap/accounts/[id].xml.ts b/apps/api/src/routes/sitemap/accounts/[id].xml.ts new file mode 100644 index 000000000000..075f4bdc6feb --- /dev/null +++ b/apps/api/src/routes/sitemap/accounts/[id].xml.ts @@ -0,0 +1,85 @@ +import lensPg from "@hey/db/lensPg"; +import { getRedis, setRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { + CACHE_AGE_1_DAY, + CACHE_AGE_INDEFINITE, + SITEMAP_BATCH_SIZE +} from "src/helpers/constants"; +import { noBody } from "src/helpers/responses"; +import { buildUrlsetXml } from "src/helpers/sitemap/buildSitemap"; + +export const config = { + api: { responseLimit: "8mb" } +}; + +// TODO: Add tests +export const get = async (req: Request, res: Response) => { + const { id } = req.params; + + if (!id) { + return noBody(res); + } + + const userAgent = req.headers["user-agent"]; + const redisKey = `sitemap:accounts:batch:${id}`; + + try { + const cachedData = await getRedis(redisKey); + let entries: { lastmod: string; loc: string }[] = []; + + if (cachedData) { + entries = JSON.parse(cachedData); + logger.info( + `(cached) [Lens] Fetched accounts sitemap for batch ${id} having ${entries.length} entries from user-agent: ${userAgent}` + ); + } else { + const offset = (Number(id) - 1) * SITEMAP_BATCH_SIZE || 0; + + const handles = await lensPg.query( + ` + SELECT h.local_name, hl.block_timestamp + FROM namespace.handle h + JOIN namespace.handle_link hl ON h.handle_id = hl.handle_id + JOIN profile.record p ON hl.token_id = p.profile_id + WHERE p.is_burnt = false + ORDER BY p.block_timestamp + LIMIT $1 + OFFSET $2; + `, + [SITEMAP_BATCH_SIZE, offset] + ); + + entries = handles.map((handle) => ({ + lastmod: handle.block_timestamp + .toISOString() + .replace("T", " ") + .replace(".000Z", "") + .split(" ")[0], + loc: `https://hey.xyz/u/${handle.local_name}` + })); + + await setRedis(redisKey, JSON.stringify(entries)); + logger.info( + `[Lens] Fetched profiles sitemap for batch ${id} having ${handles.length} entries from user-agent: ${userAgent}` + ); + } + + const xml = buildUrlsetXml(entries); + + return res + .status(200) + .setHeader("Content-Type", "text/xml") + .setHeader( + "Cache-Control", + entries.length === SITEMAP_BATCH_SIZE + ? CACHE_AGE_INDEFINITE + : CACHE_AGE_1_DAY + ) + .send(xml); + } catch (error) { + return catchedError(res, error); + } +}; diff --git a/apps/api/src/routes/sitemap/others.xml.ts b/apps/api/src/routes/sitemap/others.xml.ts new file mode 100644 index 000000000000..9ba63a1c2865 --- /dev/null +++ b/apps/api/src/routes/sitemap/others.xml.ts @@ -0,0 +1,29 @@ +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { buildUrlsetXml } from "src/helpers/sitemap/buildSitemap"; + +export const get = async (req: Request, res: Response) => { + const userAgent = req.headers["user-agent"]; + + try { + const sitemaps = [ + "https://hey.xyz", + "https://hey.xyz/explore", + "https://hey.xyz/thanks", + "https://hey.xyz/terms", + "https://hey.xyz/privacy", + "https://hey.xyz/guidelines", + "https://hey.xyz/copyright" + ]; + + const entries = sitemaps.map((sitemap) => ({ loc: sitemap })); + const xml = buildUrlsetXml(entries); + + logger.info(`[Lens] Fetched other sitemaps from user-agent: ${userAgent}`); + + return res.status(200).setHeader("Content-Type", "text/xml").send(xml); + } catch (error) { + return catchedError(res, error); + } +}; diff --git a/apps/api/src/routes/sitemap/posts.xml.ts b/apps/api/src/routes/sitemap/posts.xml.ts new file mode 100644 index 000000000000..588420c78216 --- /dev/null +++ b/apps/api/src/routes/sitemap/posts.xml.ts @@ -0,0 +1,50 @@ +import lensPg from "@hey/db/lensPg"; +import { getRedis, setRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { CACHE_AGE_1_DAY, SITEMAP_BATCH_SIZE } from "src/helpers/constants"; +import { buildSitemapXml } from "src/helpers/sitemap/buildSitemap"; + +export const get = async (req: Request, res: Response) => { + const userAgent = req.headers["user-agent"]; + const redisKey = "sitemap:posts:total"; + + try { + const cachedData = await getRedis(redisKey); + let totalPosts: number; + + if (cachedData) { + totalPosts = Number(cachedData); + logger.info(`[Lens] Fetched totalPosts from Redis: ${totalPosts}`); + } else { + const posts = await lensPg.query(` + SELECT COUNT(*) AS count + FROM publication.record pr + WHERE pr.publication_type = 'POST' AND pr.is_hidden = false + `); + + totalPosts = Number(posts[0]?.count) || 0; + await setRedis(redisKey, totalPosts); + logger.info(`[Lens] Fetched totalPosts from DB: ${totalPosts}`); + } + + const totalBatches = Math.ceil(totalPosts / SITEMAP_BATCH_SIZE); + const entries = Array.from({ length: totalBatches }, (_, index) => ({ + loc: `https://api.hey.xyz/sitemap/posts/${index + 1}.xml` + })); + const xml = buildSitemapXml(entries); + + logger.info( + `[Lens] Fetched all posts sitemap index having ${totalBatches} batches from user-agent: ${userAgent}` + ); + + return res + .status(200) + .setHeader("Content-Type", "text/xml") + .setHeader("Cache-Control", CACHE_AGE_1_DAY) + .send(xml); + } catch (error) { + return catchedError(res, error); + } +}; diff --git a/apps/api/src/routes/sitemap/posts/[id].xml.ts b/apps/api/src/routes/sitemap/posts/[id].xml.ts new file mode 100644 index 000000000000..02e2976bd20b --- /dev/null +++ b/apps/api/src/routes/sitemap/posts/[id].xml.ts @@ -0,0 +1,83 @@ +import lensPg from "@hey/db/lensPg"; +import { getRedis, setRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { + CACHE_AGE_1_DAY, + CACHE_AGE_INDEFINITE, + SITEMAP_BATCH_SIZE +} from "src/helpers/constants"; +import { noBody } from "src/helpers/responses"; +import { buildUrlsetXml } from "src/helpers/sitemap/buildSitemap"; + +export const config = { + api: { responseLimit: "8mb" } +}; + +// TODO: Add tests +export const get = async (req: Request, res: Response) => { + const { id } = req.params; + + if (!id) { + return noBody(res); + } + + const userAgent = req.headers["user-agent"]; + const redisKey = `sitemap:posts:batch:${id}`; + + try { + const cachedData = await getRedis(redisKey); + let entries: { lastmod: string; loc: string }[] = []; + + if (cachedData) { + entries = JSON.parse(cachedData); + logger.info( + `(cached) [Lens] Fetched posts sitemap for batch ${id} having ${entries.length} entries from user-agent: ${userAgent}` + ); + } else { + const offset = (Number(id) - 1) * SITEMAP_BATCH_SIZE || 0; + + const posts = await lensPg.query( + ` + SELECT pr.publication_id, pr.block_timestamp + FROM publication.record pr + WHERE pr.publication_type = 'POST' AND pr.is_hidden = false + ORDER BY pr.block_timestamp + LIMIT $1 + OFFSET $2; + `, + [SITEMAP_BATCH_SIZE, offset] + ); + + entries = posts.map((post) => ({ + lastmod: post.block_timestamp + .toISOString() + .replace("T", " ") + .replace(".000Z", "") + .split(" ")[0], + loc: `https://hey.xyz/posts/${post.publication_id}` + })); + + await setRedis(redisKey, JSON.stringify(entries)); + logger.info( + `[Lens] Fetched posts sitemap for batch ${id} having ${posts.length} entries from user-agent: ${userAgent}` + ); + } + + const xml = buildUrlsetXml(entries); + + return res + .status(200) + .setHeader("Content-Type", "text/xml") + .setHeader( + "Cache-Control", + entries.length === SITEMAP_BATCH_SIZE + ? CACHE_AGE_INDEFINITE + : CACHE_AGE_1_DAY + ) + .send(xml); + } catch (error) { + return catchedError(res, error); + } +}; diff --git a/apps/api/src/routes/sitemap/sitemap.xml.ts b/apps/api/src/routes/sitemap/sitemap.xml.ts new file mode 100644 index 000000000000..4e0321968824 --- /dev/null +++ b/apps/api/src/routes/sitemap/sitemap.xml.ts @@ -0,0 +1,29 @@ +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { buildSitemapXml } from "src/helpers/sitemap/buildSitemap"; + +export const get = async (req: Request, res: Response) => { + const userAgent = req.headers["user-agent"]; + + try { + const sitemaps = [ + "https://api.hey.xyz/sitemap/posts.xml", + "https://api.hey.xyz/sitemap/accounts.xml", + "https://api.hey.xyz/sitemap/others.xml" + ]; + + const entries = sitemaps.map((sitemap) => ({ + loc: sitemap + })); + const xml = buildSitemapXml(entries); + + logger.info( + `[Lens] Fetched all sitemaps index from user-agent: ${userAgent}` + ); + + return res.status(200).setHeader("Content-Type", "text/xml").send(xml); + } catch (error) { + return catchedError(res, error); + } +}; diff --git a/apps/api/src/routes/staff-picks/index.ts b/apps/api/src/routes/staff-picks/index.ts new file mode 100644 index 000000000000..b8110e09850a --- /dev/null +++ b/apps/api/src/routes/staff-picks/index.ts @@ -0,0 +1,49 @@ +import { PermissionId } from "@hey/data/permissions"; +import prisma from "@hey/db/prisma/db/client"; +import { generateMediumExpiry, getRedis, setRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { CACHE_AGE_30_MINS } from "src/helpers/constants"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; + +const getRandomPicks = (data: any[]) => { + const random = data.sort(() => Math.random() - Math.random()); + return random.slice(0, 150); +}; + +export const get = [ + rateLimiter({ requests: 100, within: 1 }), + async (_: Request, res: Response) => { + try { + const cacheKey = "staff-picks"; + const cachedData = await getRedis(cacheKey); + + if (cachedData) { + logger.info("(cached) Staff picks fetched"); + return res + .status(200) + .setHeader("Cache-Control", CACHE_AGE_30_MINS) + .json({ + result: getRandomPicks(JSON.parse(cachedData)), + success: true + }); + } + + const accountPermission = await prisma.profilePermission.findMany({ + select: { profileId: true }, + where: { enabled: true, permissionId: PermissionId.StaffPick } + }); + + await setRedis(cacheKey, accountPermission, generateMediumExpiry()); + logger.info("Staff picks fetched"); + + return res + .status(200) + .setHeader("Cache-Control", CACHE_AGE_30_MINS) + .json({ result: getRandomPicks(accountPermission), success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/stats/post/views.ts b/apps/api/src/routes/stats/post/views.ts new file mode 100644 index 000000000000..fc88d6409b71 --- /dev/null +++ b/apps/api/src/routes/stats/post/views.ts @@ -0,0 +1,60 @@ +import clickhouseClient from "@hey/db/clickhouseClient"; +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; +import { invalidBody, noBody } from "src/helpers/responses"; +import { array, object, string } from "zod"; + +interface ExtensionRequest { + ids: string[]; +} + +const validationSchema = object({ + ids: array(string().max(2000)) +}); + +export const post = [ + rateLimiter({ requests: 250, within: 1 }), + async (req: Request, res: Response) => { + const { body } = req; + + if (!body) { + return noBody(res); + } + + const validation = validationSchema.safeParse(body); + + if (!validation.success) { + return invalidBody(res); + } + + const { ids } = body as ExtensionRequest; + + try { + const totalImpressions = await clickhouseClient.query({ + format: "JSONEachRow", + query: ` + SELECT publication, count + FROM total_impressions_per_publication_mv FINAL + WHERE publication IN (${ids.map((id) => `'${id}'`).join(",")}); + ` + }); + + const result = await totalImpressions.json<{ + count: number; + publication: string; + }>(); + + const viewCounts = result.map((row) => ({ + id: row.publication, + views: Number(row.count) + })); + logger.info(`Fetched post views for ${ids.length} posts`); + + return res.status(200).json({ success: true, views: viewCounts }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/sts/token.ts b/apps/api/src/routes/sts/token.ts new file mode 100644 index 000000000000..c6fd37d4d52a --- /dev/null +++ b/apps/api/src/routes/sts/token.ts @@ -0,0 +1,57 @@ +import { AssumeRoleCommand, STSClient } from "@aws-sdk/client-sts"; +import { EVER_API, EVER_BUCKET, EVER_REGION } from "@hey/data/constants"; +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; + +const params = { + DurationSeconds: 900, + Policy: `{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "s3:PutObject", + "s3:GetObject", + "s3:AbortMultipartUpload" + ], + "Resource": [ + "arn:aws:s3:::${EVER_BUCKET}/*" + ] + } + ] + }` +}; + +export const get = [ + rateLimiter({ requests: 50, within: 1 }), + async (_: Request, res: Response) => { + try { + const accessKeyId = process.env.EVER_ACCESS_KEY as string; + const secretAccessKey = process.env.EVER_ACCESS_SECRET as string; + const stsClient = new STSClient({ + credentials: { accessKeyId, secretAccessKey }, + endpoint: EVER_API, + region: EVER_REGION + }); + const command = new AssumeRoleCommand({ + ...params, + RoleArn: undefined, + RoleSessionName: undefined + }); + const { Credentials: credentials } = await stsClient.send(command); + logger.info("STS token generated"); + + return res.status(200).json({ + accessKeyId: credentials?.AccessKeyId, + secretAccessKey: credentials?.SecretAccessKey, + sessionToken: credentials?.SessionToken, + success: true + }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/tips/create.ts b/apps/api/src/routes/tips/create.ts new file mode 100644 index 000000000000..fc4161c85105 --- /dev/null +++ b/apps/api/src/routes/tips/create.ts @@ -0,0 +1,73 @@ +import { Regex } from "@hey/data/regex"; +import prisma from "@hey/db/prisma/db/client"; +import logger from "@hey/helpers/logger"; +import parseJwt from "@hey/helpers/parseJwt"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; +import validateLensAccount from "src/helpers/middlewares/validateLensAccount"; +import { invalidBody, noBody } from "src/helpers/responses"; +import { number, object, string } from "zod"; + +interface ExtensionRequest { + amount: number; + fromAddress: string; + id: string; + toAddress: string; + tokenAddress: string; + txHash: string; +} + +const validationSchema = object({ + amount: number(), + fromAddress: string().regex(Regex.ethereumAddress), + id: string(), + toAddress: string().regex(Regex.ethereumAddress), + tokenAddress: string().regex(Regex.ethereumAddress), + txHash: string().regex(Regex.txHash) +}); + +export const post = [ + rateLimiter({ requests: 50, within: 1 }), + validateLensAccount, + async (req: Request, res: Response) => { + const { body } = req; + + if (!body) { + return noBody(res); + } + + const validation = validationSchema.safeParse(body); + + if (!validation.success) { + return invalidBody(res); + } + + const { amount, fromAddress, id, toAddress, tokenAddress, txHash } = + body as ExtensionRequest; + + try { + const idToken = req.headers["x-id-token"] as string; + const payload = parseJwt(idToken); + + const tip = await prisma.tip.create({ + data: { + amount, + fromAddress, + fromProfileId: payload.id, + publicationId: id, + toAddress, + tokenAddress, + toProfileId: id.split("-")[0], + txHash + } + }); + + logger.info(`Created a tip ${tip.id}`); + + return res.status(200).json({ result: tip, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/tips/get.ts b/apps/api/src/routes/tips/get.ts new file mode 100644 index 000000000000..cc3cfc5aa609 --- /dev/null +++ b/apps/api/src/routes/tips/get.ts @@ -0,0 +1,69 @@ +import prisma from "@hey/db/prisma/db/client"; +import logger from "@hey/helpers/logger"; +import parseJwt from "@hey/helpers/parseJwt"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; +import { invalidBody, noBody } from "src/helpers/responses"; +import { array, object, string } from "zod"; + +interface ExtensionRequest { + ids: string[]; +} + +const validationSchema = object({ + ids: array(string()).min(1).max(500) +}); + +export const post = [ + rateLimiter({ requests: 250, within: 1 }), + async (req: Request, res: Response) => { + const { body } = req; + + if (!body) { + return noBody(res); + } + + const validation = validationSchema.safeParse(body); + + if (!validation.success) { + return invalidBody(res); + } + + const { ids } = body as ExtensionRequest; + + try { + const idToken = req.headers["x-id-token"] as string; + const payload = parseJwt(idToken); + const profileId = payload.id; + + const [hasTipped, tipCounts] = await prisma.$transaction([ + prisma.tip.findMany({ + select: { publicationId: true }, + where: { fromProfileId: profileId, publicationId: { in: ids } } + }), + prisma.tip.groupBy({ + _count: { publicationId: true }, + by: ["publicationId"], + orderBy: { publicationId: "asc" }, + where: { publicationId: { in: ids } } + }) + ]); + + const hasTippedMap = new Set(hasTipped.map((tip) => tip.publicationId)); + + const result = tipCounts.map(({ _count, publicationId }) => ({ + // @ts-ignore + count: _count.publicationId, + id: publicationId, + tipped: hasTippedMap.has(publicationId) + })); + + logger.info(`Fetched tips for ${ids.length} posts`); + + return res.status(200).json({ result, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/tokens/all.ts b/apps/api/src/routes/tokens/all.ts new file mode 100644 index 000000000000..c30ddbc7a422 --- /dev/null +++ b/apps/api/src/routes/tokens/all.ts @@ -0,0 +1,39 @@ +import prisma from "@hey/db/prisma/db/client"; +import { getRedis, setRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import { CACHE_AGE_1_DAY } from "src/helpers/constants"; +import { rateLimiter } from "src/helpers/middlewares/rateLimiter"; + +export const get = [ + rateLimiter({ requests: 250, within: 1 }), + async (_: Request, res: Response) => { + try { + const cacheKey = "allowed-tokens"; + const cachedData = await getRedis(cacheKey); + + if (cachedData) { + logger.info("(cached) All tokens fetched"); + return res + .status(200) + .setHeader("Cache-Control", CACHE_AGE_1_DAY) + .json({ result: JSON.parse(cachedData), success: true }); + } + + const allowedToken = await prisma.allowedToken.findMany({ + orderBy: { priority: "desc" } + }); + + await setRedis(cacheKey, allowedToken); + logger.info("All tokens fetched"); + + return res + .status(200) + .setHeader("Cache-Control", CACHE_AGE_1_DAY) + .json({ result: allowedToken, success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/routes/webhooks/signup.ts b/apps/api/src/routes/webhooks/signup.ts new file mode 100644 index 000000000000..78f966b9d814 --- /dev/null +++ b/apps/api/src/routes/webhooks/signup.ts @@ -0,0 +1,41 @@ +import type { Request, Response } from "express"; +import catchedError from "src/helpers/catchedError"; +import validateSecret from "src/helpers/middlewares/validateSecret"; +import { invalidBody, noBody } from "src/helpers/responses"; +import sendSignupNotificationToSlack from "src/helpers/webhooks/signup/sendSignupNotificationToSlack"; +import { any, object } from "zod"; + +interface ExtensionRequest { + event: { activity: any }; +} + +const validationSchema = object({ + event: object({ activity: any() }) +}); + +export const post = [ + validateSecret, + (req: Request, res: Response) => { + const { body } = req; + + if (!body) { + return noBody(res); + } + + const validation = validationSchema.safeParse(body); + + if (!validation.success) { + return invalidBody(res); + } + + const { event } = body as ExtensionRequest; + + try { + sendSignupNotificationToSlack(event.activity[0].hash); + + return res.status(200).json({ success: true }); + } catch (error) { + return catchedError(res, error); + } + } +]; diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts new file mode 100644 index 000000000000..1a54c511bdfc --- /dev/null +++ b/apps/api/src/server.ts @@ -0,0 +1,50 @@ +import logger from "@hey/helpers/logger"; +import cors from "cors"; +import dotenv from "dotenv"; +import express from "express"; +import { router } from "express-file-routing"; + +// Load environment variables +dotenv.config({ override: true }); + +const app = express(); + +app.disable("x-powered-by"); + +// Middleware configuration +app.use(cors()); +app.use(express.json({ limit: "1mb" })); + +// Increase request timeout +app.use((req, _, next) => { + req.setTimeout(120000); // 2 minutes + next(); +}); + +// Log request aborted +app.use((req, _, next) => { + req.on("aborted", () => { + logger.error("Request aborted by the client"); + }); + next(); +}); + +const startServer = async () => { + // Route configuration + app.use("/", await router()); + + // Catch all valid pages that don't match any route and send custom 404 + app.use((_, res) => { + res.status(404).json({ message: "404", success: false }); + }); + + // Start the server + app.listen(4784, () => { + logger.info("Server is listening on port 4784..."); + }); +}; + +// Initialize routes +startServer().catch((error) => { + logger.error("Failed to start API server", error); +}); diff --git a/apps/api/tests/account/get.spec.ts b/apps/api/tests/account/get.spec.ts new file mode 100644 index 000000000000..dfc396d55d00 --- /dev/null +++ b/apps/api/tests/account/get.spec.ts @@ -0,0 +1,36 @@ +import { TEST_LENS_ID, TEST_SUSPENDED_LENS_ID } from "@hey/data/constants"; +import { delRedis } from "@hey/db/redisClient"; +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import { describe, expect, test } from "vitest"; + +describe("GET /account/get", () => { + test("should return 400 if no id is provided", async () => { + try { + await axios.get(`${TEST_URL}/account/get`); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 200 with status and theme", async () => { + await delRedis(`account:${TEST_LENS_ID}`); + const { data, status } = await axios.get(`${TEST_URL}/account/get`, { + params: { id: TEST_LENS_ID } + }); + + expect(status).toBe(200); + expect(data.result.status.emoji).toBe("😀"); + expect(data.result.status.message).toBe("Status message"); + expect(data.result.isSuspended).toBe(false); + }); + + test("should return 200 and suspended status for a suspended account", async () => { + const { data, status } = await axios.get(`${TEST_URL}/account/get`, { + params: { id: TEST_SUSPENDED_LENS_ID } + }); + + expect(status).toBe(200); + expect(data.result.isSuspended).toBe(true); + }); +}); diff --git a/apps/api/tests/account/status/update.spec.ts b/apps/api/tests/account/status/update.spec.ts new file mode 100644 index 000000000000..8c79d6e7b6ee --- /dev/null +++ b/apps/api/tests/account/status/update.spec.ts @@ -0,0 +1,43 @@ +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import getTestAuthHeaders from "tests/helpers/getTestAuthHeaders"; +import { describe, expect, test } from "vitest"; + +describe("POST /account/status/update", () => { + test("should return 400 if no body is provided", async () => { + try { + await axios.post( + `${TEST_URL}/account/status/update`, + {}, + { headers: getTestAuthHeaders() } + ); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 400 for invalid body (missing required fields)", async () => { + try { + await axios.post( + `${TEST_URL}/account/status/update`, + { randomField: "invalid" }, + { headers: getTestAuthHeaders() } + ); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 200 and update the account status", async () => { + const { data, status } = await axios.post( + `${TEST_URL}/account/status/update`, + { emoji: "😀", message: "Status message" }, + { headers: getTestAuthHeaders() } + ); + + expect(status).toBe(200); + expect(data.result).toBeDefined(); + expect(data.result.emoji).toBe("😀"); + expect(data.result.message).toBe("Status message"); + }); +}); diff --git a/apps/api/tests/analytics/impressions.spec.ts b/apps/api/tests/analytics/impressions.spec.ts new file mode 100644 index 000000000000..5684d4efce83 --- /dev/null +++ b/apps/api/tests/analytics/impressions.spec.ts @@ -0,0 +1,28 @@ +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import getTestAuthHeaders from "tests/helpers/getTestAuthHeaders"; +import { describe, expect, test } from "vitest"; + +describe("GET /analytics/impressions", () => { + test("should return 200 and valid analytics data", async () => { + const { data, status } = await axios.get( + `${TEST_URL}/analytics/impressions`, + { headers: getTestAuthHeaders() } + ); + + expect(status).toBe(200); + expect(data.result).toBeInstanceOf(Array); + for (const item of data.result) { + expect(item).toHaveProperty("date"); + expect(item).toHaveProperty("impressions"); + } + }); + + test("should return 401 if the id token is missing", async () => { + try { + await axios.get(`${TEST_URL}/analytics/impressions`); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); +}); diff --git a/apps/api/tests/analytics/overview.spec.ts b/apps/api/tests/analytics/overview.spec.ts new file mode 100644 index 000000000000..9c1c41213bb4 --- /dev/null +++ b/apps/api/tests/analytics/overview.spec.ts @@ -0,0 +1,34 @@ +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import getTestAuthHeaders from "tests/helpers/getTestAuthHeaders"; +import { describe, expect, test } from "vitest"; + +describe("GET /analytics/overview", () => { + test("should return 200 and valid analytics overview data", async () => { + const { data, status } = await axios.get(`${TEST_URL}/analytics/overview`, { + headers: getTestAuthHeaders() + }); + + expect(status).toBe(200); + expect(data.result).toBeInstanceOf(Array); + for (const item of data.result) { + expect(item).toHaveProperty("date"); + expect(item).toHaveProperty("likes"); + expect(item).toHaveProperty("comments"); + expect(item).toHaveProperty("collects"); + expect(item).toHaveProperty("mirrors"); + expect(item).toHaveProperty("quotes"); + expect(item).toHaveProperty("mentions"); + expect(item).toHaveProperty("follows"); + expect(item).toHaveProperty("bookmarks"); + } + }); + + test("should return 401 if the id token is missing", async () => { + try { + await axios.get(`${TEST_URL}/analytics/overview`); + } catch (error: any) { + expect(error.response.status).toBe(401); // Expect 401 Unauthorized if no id token is provided + } + }); +}); diff --git a/apps/api/tests/avatar.spec.ts b/apps/api/tests/avatar.spec.ts new file mode 100644 index 000000000000..051faa169c3f --- /dev/null +++ b/apps/api/tests/avatar.spec.ts @@ -0,0 +1,37 @@ +import { IPFS_GATEWAY, TEST_LENS_ID } from "@hey/data/constants"; +import axios from "axios"; +import { describe, expect, test } from "vitest"; +import { TEST_URL } from "./helpers/constants"; + +describe("GET /avatar", () => { + test("should return 400 if no id is provided", async () => { + try { + await axios.get(`${TEST_URL}/avatar`); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return svg image if id is provided", async () => { + const { data, status, headers } = await axios.get(`${TEST_URL}/avatar`, { + params: { id: TEST_LENS_ID } + }); + + expect(status).toBe(200); + expect(headers["content-type"]).toBe("image/svg+xml; charset=utf-8"); + expect(data).toContain(" { + const { status, headers } = await axios.get(`${TEST_URL}/avatar`, { + params: { id: "invalid-id" }, + maxRedirects: 0, + validateStatus: (status) => status === 302 + }); + + expect(status).toBe(302); + expect(headers["location"]).toBe( + `${IPFS_GATEWAY}/Qmb4XppdMDCsS7KCL8nCJo8pukEWeqL4bTghURYwYiG83i/cropped_image.png` + ); + }); +}); diff --git a/apps/api/tests/badges/hasHeyNft.spec.ts b/apps/api/tests/badges/hasHeyNft.spec.ts new file mode 100644 index 000000000000..3db42ffc5bdf --- /dev/null +++ b/apps/api/tests/badges/hasHeyNft.spec.ts @@ -0,0 +1,49 @@ +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import { describe, expect, test } from "vitest"; + +describe("GET /badges/hasHeyNft", () => { + test("should return 400 if no id or address is provided", async () => { + try { + await axios.get(`${TEST_URL}/badges/hasHeyNft`); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 200 and confirm NFT ownership with id", async () => { + const { data, status } = await axios.get(`${TEST_URL}/badges/hasHeyNft`, { + params: { id: "0x0d" } + }); + + expect(status).toBe(200); + expect(data.hasHeyNft).toBe(true); + }); + + test("should return 200 and confirm NFT ownership with address", async () => { + const { data, status } = await axios.get(`${TEST_URL}/badges/hasHeyNft`, { + params: { address: "0x03Ba34f6Ea1496fa316873CF8350A3f7eaD317EF" } + }); + + expect(status).toBe(200); + expect(data.hasHeyNft).toBe(true); + }); + + test("should return 200 and no NFT ownership when profile doesn't have it", async () => { + const { data, status } = await axios.get(`${TEST_URL}/badges/hasHeyNft`, { + params: { id: "0x01" } + }); + + expect(status).toBe(200); + expect(data.hasHeyNft).toBe(false); + }); + + test("should return 200 and no NFT ownership when address doesn't have it", async () => { + const { data, status } = await axios.get(`${TEST_URL}/badges/hasHeyNft`, { + params: { address: "0x0Cfc642C90ED27be228E504307049230545b2981" } + }); + + expect(status).toBe(200); + expect(data.hasHeyNft).toBe(false); + }); +}); diff --git a/apps/api/tests/badges/isHeyAccount.spec.ts b/apps/api/tests/badges/isHeyAccount.spec.ts new file mode 100644 index 000000000000..75d6e48bf363 --- /dev/null +++ b/apps/api/tests/badges/isHeyAccount.spec.ts @@ -0,0 +1,53 @@ +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import { describe, expect, test } from "vitest"; + +describe("GET /badges/isHeyAccount", () => { + test("should return 400 if no id or address is provided", async () => { + try { + await axios.get(`${TEST_URL}/badges/isHeyAccount`); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 200 and confirm profile badge ownership with id", async () => { + const { data, status } = await axios.get( + `${TEST_URL}/badges/isHeyAccount`, + { params: { id: "0x0415da" } } + ); + + expect(status).toBe(200); + expect(data.isHeyAccount).toBe(true); + }); + + test("should return 200 and confirm profile badge ownership with address", async () => { + const { data, status } = await axios.get( + `${TEST_URL}/badges/isHeyAccount`, + { params: { address: "0x0Cfc642C90ED27be228E504307049230545b2981" } } + ); + + expect(status).toBe(200); + expect(data.isHeyAccount).toBe(true); + }); + + test("should return 200 and no badge ownership when profile doesn't have it", async () => { + const { data, status } = await axios.get( + `${TEST_URL}/badges/isHeyAccount`, + { params: { id: "0x0d" } } + ); + + expect(status).toBe(200); + expect(data.isHeyAccount).toBe(false); + }); + + test("should return 200 and no badge ownership when address doesn't have it", async () => { + const { data, status } = await axios.get( + `${TEST_URL}/badges/isHeyAccount`, + { params: { address: "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045" } } + ); + + expect(status).toBe(200); + expect(data.isHeyAccount).toBe(false); + }); +}); diff --git a/apps/api/tests/email/update.spec.ts b/apps/api/tests/email/update.spec.ts new file mode 100644 index 000000000000..b595df7c12cf --- /dev/null +++ b/apps/api/tests/email/update.spec.ts @@ -0,0 +1,72 @@ +import { faker } from "@faker-js/faker"; +import { TEST_LENS_ID } from "@hey/data/constants"; +import prisma from "@hey/db/prisma/db/client"; +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import getTestAuthHeaders from "tests/helpers/getTestAuthHeaders"; +import { describe, expect, test } from "vitest"; + +describe("POST /email/update", () => { + test("should return 400 if body is missing", async () => { + try { + await axios.post( + `${TEST_URL}/email/update`, + {}, + { headers: getTestAuthHeaders() } + ); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 400 for invalid email", async () => { + try { + await axios.post( + `${TEST_URL}/email/update`, + { email: "gm@mail3.me" }, + { headers: getTestAuthHeaders() } + ); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 200 and send verification email", async () => { + const { data, status } = await axios.post( + `${TEST_URL}/email/update`, + { email: faker.internet.email() }, + { headers: getTestAuthHeaders() } + ); + + expect(status).toBe(200); + expect(data.success).toBe(true); + }); + + test("should return 200 and do nothing if email already exists", async () => { + const email = faker.internet.email(); + try { + await prisma.email.create({ + data: { email, id: TEST_LENS_ID } + }); + } catch {} + + const { data, status } = await axios.post( + `${TEST_URL}/email/update`, + { email, resend: false }, + { headers: getTestAuthHeaders() } + ); + + expect(status).toBe(200); + expect(data.success).toBe(true); + }); + + test("should return 401 if the id token is missing", async () => { + try { + await axios.post(`${TEST_URL}/email/update`, { + email: faker.internet.email() + }); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); +}); diff --git a/apps/api/tests/email/verify.spec.ts b/apps/api/tests/email/verify.spec.ts new file mode 100644 index 000000000000..dba7b54efec0 --- /dev/null +++ b/apps/api/tests/email/verify.spec.ts @@ -0,0 +1,59 @@ +import { faker } from "@faker-js/faker"; +import prisma from "@hey/db/prisma/db/client"; +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import getTestAuthHeaders from "tests/helpers/getTestAuthHeaders"; +import { describe, expect, test } from "vitest"; + +describe("GET /email/verify", () => { + test("should return 400 if token is missing", async () => { + try { + await axios.get(`${TEST_URL}/email/verify`, { + headers: getTestAuthHeaders() + }); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should verify email and redirect if token is valid", async () => { + const email = faker.internet.email(); + const token = faker.string.uuid(); + const id = faker.string.uuid(); + + await prisma.email.create({ + data: { + id, + email, + verificationToken: token, + verified: false, + tokenExpiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000) + } + }); + + const { status, headers } = await axios.get(`${TEST_URL}/email/verify`, { + params: { token }, + maxRedirects: 0, + validateStatus: (status) => status === 302 + }); + + expect(status).toBe(302); + expect(headers.location).toBe("https://hey.xyz"); + + const updatedEmail = await prisma.email.findUnique({ where: { email } }); + expect(updatedEmail?.verified).toBe(true); + expect(updatedEmail?.verificationToken).toBeNull(); + expect(updatedEmail?.tokenExpiresAt).toBeNull(); + }); + + test("should return 400 for an invalid or expired token", async () => { + try { + await axios.get(`${TEST_URL}/email/verify`, { + params: { token: "invalidToken" } + }); + } catch (error: any) { + expect(error.response.status).toBe(400); + expect(error.response.data).toBe("Something went wrong"); + } + }); +}); diff --git a/apps/api/tests/ens/index.spec.ts b/apps/api/tests/ens/index.spec.ts new file mode 100644 index 000000000000..6110eb97d11b --- /dev/null +++ b/apps/api/tests/ens/index.spec.ts @@ -0,0 +1,52 @@ +import axios from "axios"; +import { describe, expect, test } from "vitest"; +import { TEST_URL } from "../helpers/constants"; + +describe("POST /ens", () => { + test("should return 400 if no body is provided", async () => { + try { + await axios.post(`${TEST_URL}/ens`, {}); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 400 for invalid addresses", async () => { + try { + await axios.post(`${TEST_URL}/ens`, { + addresses: ["invalid-address"] + }); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 200 and valid ENS names for correct addresses", async () => { + const { data, status } = await axios.post(`${TEST_URL}/ens`, { + addresses: ["0x03Ba34f6Ea1496fa316873CF8350A3f7eaD317EF"] + }); + + expect(status).toBe(200); + expect(data.result).toEqual(["yoginth.com"]); + }); + + test("should return 400 for too many addresses", async () => { + const addresses = new Array(101).fill( + "0x03Ba34f6Ea1496fa316873CF8350A3f7eaD317EF" + ); + + try { + await axios.post(`${TEST_URL}/ens`, { addresses }); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 400 for invalid ethereum addresse", async () => { + try { + await axios.post(`${TEST_URL}/ens`, { addresses: ["0xinvalid"] }); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); +}); diff --git a/apps/api/tests/export/collects.spec.ts b/apps/api/tests/export/collects.spec.ts new file mode 100644 index 000000000000..24851a3dcb03 --- /dev/null +++ b/apps/api/tests/export/collects.spec.ts @@ -0,0 +1,49 @@ +import { TEST_LENS_ID } from "@hey/data/constants"; +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import getTestAuthHeaders from "tests/helpers/getTestAuthHeaders"; +import { describe, expect, test } from "vitest"; + +describe("GET /export/collects", () => { + test("should return 400 if no id is provided", async () => { + try { + await axios.get(`${TEST_URL}/export/collects`, { + headers: getTestAuthHeaders() + }); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 401 if the id token doesn't match the target profile", async () => { + try { + await axios.get(`${TEST_URL}/export/collects`, { + params: { id: "0x0d-0x04b9" }, + headers: getTestAuthHeaders() + }); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); + + test( + "should return 200 and download CSV of collected addresses", + async () => { + const { status, headers } = await axios.get( + `${TEST_URL}/export/collects`, + { + params: { id: `${TEST_LENS_ID}-0x06` }, + headers: getTestAuthHeaders(), + responseType: "blob" + } + ); + + expect(status).toBe(200); + expect(headers["content-type"]).toBe("text/csv; charset=utf-8"); + expect(headers["content-disposition"]).toContain( + `attachment; filename="collect_addresses_${TEST_LENS_ID}-0x06.csv"` + ); + }, + { timeout: 100000 } + ); +}); diff --git a/apps/api/tests/health.spec.ts b/apps/api/tests/health.spec.ts new file mode 100644 index 000000000000..a6a35d6b3832 --- /dev/null +++ b/apps/api/tests/health.spec.ts @@ -0,0 +1,12 @@ +import axios from "axios"; +import { describe, expect, test } from "vitest"; +import { TEST_URL } from "./helpers/constants"; + +describe("GET /health", () => { + test("should respond with status 200 and correct health information", async () => { + const { data, status } = await axios.get(`${TEST_URL}/health`); + + expect(status).toBe(200); + expect(data.ping).toBe("pong"); + }); +}); diff --git a/apps/api/tests/helpers/constants.ts b/apps/api/tests/helpers/constants.ts new file mode 100644 index 000000000000..2456eb05f573 --- /dev/null +++ b/apps/api/tests/helpers/constants.ts @@ -0,0 +1 @@ +export const TEST_URL = "http://localhost:4784"; diff --git a/apps/api/tests/helpers/getTestAuthHeaders.ts b/apps/api/tests/helpers/getTestAuthHeaders.ts new file mode 100644 index 000000000000..b9419e25b7e8 --- /dev/null +++ b/apps/api/tests/helpers/getTestAuthHeaders.ts @@ -0,0 +1,19 @@ +const TEST_AUTH_TOKEN = process.env.TEST_AUTH_TOKEN; +const TEST_SUSPENDED_AUTH_TOKEN = process.env.TEST_SUSPENDED_AUTH_TOKEN; + +const getTestAuthHeaders = (type: "default" | "suspended" = "default") => { + switch (type) { + case "suspended": + return { + "X-Access-Token": TEST_SUSPENDED_AUTH_TOKEN, + "X-Id-Token": TEST_SUSPENDED_AUTH_TOKEN + }; + default: + return { + "X-Access-Token": TEST_AUTH_TOKEN, + "X-Id-Token": TEST_AUTH_TOKEN + }; + } +}; + +export default getTestAuthHeaders; diff --git a/apps/api/tests/internal/account/get.spec.ts b/apps/api/tests/internal/account/get.spec.ts new file mode 100644 index 000000000000..fefebf11641e --- /dev/null +++ b/apps/api/tests/internal/account/get.spec.ts @@ -0,0 +1,74 @@ +import { faker } from "@faker-js/faker"; +import { TEST_LENS_ID } from "@hey/data/constants"; +import { Permission, PermissionId } from "@hey/data/permissions"; +import prisma from "@hey/db/prisma/db/client"; +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import getTestAuthHeaders from "tests/helpers/getTestAuthHeaders"; +import { describe, expect, test } from "vitest"; + +describe("GET /internal/account/get", () => { + test("should return 200 and the internal account data", async () => { + const preference = await prisma.preference.create({ + data: { + id: faker.string.uuid(), + appIcon: 1, + highSignalNotificationFilter: true, + developerMode: true + } + }); + + const [email] = await Promise.all([ + prisma.email.create({ + data: { + id: preference.id, + email: faker.internet.email(), + verified: true + } + }), + prisma.profilePermission.create({ + data: { profileId: preference.id, permissionId: PermissionId.Beta } + }), + prisma.membershipNft.create({ + data: { id: preference.id, dismissedOrMinted: true } + }) + ]); + + const { data, status } = await axios.get( + `${TEST_URL}/internal/account/get`, + { params: { id: preference.id }, headers: getTestAuthHeaders() } + ); + + expect(status).toBe(200); + expect(data.result).toBeDefined(); + expect(data.result.email).toBe(email.email); + expect(data.result.emailVerified).toBe(email.verified); + expect(data.result.appIcon).toBe(preference.appIcon); + expect(data.result.permissions).toContain(Permission.Beta); + expect(data.result.hasDismissedOrMintedMembershipNft).toBe(true); + expect(data.result.highSignalNotificationFilter).toBe( + preference.highSignalNotificationFilter + ); + expect(data.result.developerMode).toBe(preference.developerMode); + }); + + test("should return 400 if id is missing", async () => { + try { + await axios.get(`${TEST_URL}/internal/account/get`, { + headers: getTestAuthHeaders() + }); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 401 if the id token is missing", async () => { + try { + await axios.get(`${TEST_URL}/internal/account/get`, { + params: { id: TEST_LENS_ID } + }); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); +}); diff --git a/apps/api/tests/internal/creator-tools/assign.spec.ts b/apps/api/tests/internal/creator-tools/assign.spec.ts new file mode 100644 index 000000000000..12ae5ea63faa --- /dev/null +++ b/apps/api/tests/internal/creator-tools/assign.spec.ts @@ -0,0 +1,90 @@ +import { faker } from "@faker-js/faker"; +import { PermissionId } from "@hey/data/permissions"; +import prisma from "@hey/db/prisma/db/client"; +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import getTestAuthHeaders from "tests/helpers/getTestAuthHeaders"; +import { beforeAll, describe, expect, test } from "vitest"; + +describe("POST /internal/creator-tools/assign", () => { + let accountId: string; + const permissionId = PermissionId.Beta; + + beforeAll(async () => { + accountId = faker.string.uuid(); + }); + + test("should enable permission for a profile", async () => { + const { data, status } = await axios.post( + `${TEST_URL}/internal/creator-tools/assign`, + { enabled: true, id: permissionId, accountId }, + { headers: getTestAuthHeaders() } + ); + + expect(status).toBe(200); + expect(data.success).toBe(true); + expect(data.enabled).toBe(true); + + const accountPermission = await prisma.profilePermission.findFirst({ + where: { profileId: accountId, permissionId } + }); + expect(accountPermission).toBeDefined(); + }); + + test("should disable permission for a profile", async () => { + const { data, status } = await axios.post( + `${TEST_URL}/internal/creator-tools/assign`, + { enabled: false, id: permissionId, accountId }, + { headers: getTestAuthHeaders() } + ); + + expect(status).toBe(200); + expect(data.success).toBe(true); + expect(data.enabled).toBe(false); + + const accountPermission = await prisma.profilePermission.findFirst({ + where: { profileId: accountId, permissionId } + }); + expect(accountPermission).toBeNull(); + }); + + test("should return 400 for invalid body", async () => { + try { + await axios.post( + `${TEST_URL}/internal/creator-tools/assign`, + {}, + { headers: getTestAuthHeaders() } + ); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 401 if the user doesn't have creator tools access", async () => { + try { + await axios.post( + `${TEST_URL}/internal/creator-tools/assign`, + { + enabled: true, + id: faker.string.uuid(), + accountId: faker.string.uuid() + }, + { headers: getTestAuthHeaders("suspended") } + ); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); + + test("should return 401 if the id token is missing", async () => { + try { + await axios.post(`${TEST_URL}/internal/creator-tools/assign`, { + enabled: true, + id: faker.string.uuid(), + accountId: faker.string.uuid() + }); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); +}); diff --git a/apps/api/tests/internal/leafwatch/account/details.spec.ts b/apps/api/tests/internal/leafwatch/account/details.spec.ts new file mode 100644 index 000000000000..03ea80ebfeff --- /dev/null +++ b/apps/api/tests/internal/leafwatch/account/details.spec.ts @@ -0,0 +1,53 @@ +import { TEST_LENS_ID } from "@hey/data/constants"; +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import getTestAuthHeaders from "tests/helpers/getTestAuthHeaders"; +import { describe, expect, test } from "vitest"; + +describe("GET /internal/leafwatch/account/details", () => { + test("should return 200 and profile details", async () => { + const { data, status } = await axios.get( + `${TEST_URL}/internal/leafwatch/account/details`, + { params: { id: TEST_LENS_ID }, headers: getTestAuthHeaders() } + ); + + expect(status).toBe(200); + expect(data.result).toBeDefined(); + expect(data.result.actor).toBe(TEST_LENS_ID); + expect(data.result.browser).toStrictEqual(expect.any(String)); + expect(data.result.city).toStrictEqual(expect.any(String)); + expect(data.result.country).toStrictEqual(expect.any(String)); + expect(data.result.events).toStrictEqual(expect.any(Number)); + }); + + test("should return 400 if id is missing", async () => { + try { + await axios.get(`${TEST_URL}/internal/leafwatch/profile/details`, { + headers: getTestAuthHeaders() + }); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 401 if the id token is missing", async () => { + try { + await axios.get(`${TEST_URL}/internal/leafwatch/account/details`, { + params: { id: TEST_LENS_ID } + }); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); + + test("should return 401 if the user is not staff", async () => { + try { + await axios.get(`${TEST_URL}/internal/leafwatch/profile/details`, { + params: { id: TEST_LENS_ID }, + headers: getTestAuthHeaders() + }); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); +}); diff --git a/apps/api/tests/internal/leafwatch/account/haveUsedHey.spec.ts b/apps/api/tests/internal/leafwatch/account/haveUsedHey.spec.ts new file mode 100644 index 000000000000..4957994aa70e --- /dev/null +++ b/apps/api/tests/internal/leafwatch/account/haveUsedHey.spec.ts @@ -0,0 +1,58 @@ +import { TEST_LENS_ID } from "@hey/data/constants"; +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import getTestAuthHeaders from "tests/helpers/getTestAuthHeaders"; +import { describe, expect, test } from "vitest"; + +describe("GET /internal/leafwatch/profile/haveUsedHey", () => { + test("should return 200 and true if the actor has used Hey", async () => { + const { data, status } = await axios.get( + `${TEST_URL}/internal/leafwatch/profile/haveUsedHey`, + { params: { id: TEST_LENS_ID }, headers: getTestAuthHeaders() } + ); + + expect(status).toBe(200); + expect(data.haveUsedHey).toBe(true); + }); + + test("should return 200 and false if the actor has never used Hey", async () => { + const { data, status } = await axios.get( + `${TEST_URL}/internal/leafwatch/profile/haveUsedHey`, + { params: { id: "0x00" }, headers: getTestAuthHeaders() } + ); + + expect(status).toBe(200); + expect(data.haveUsedHey).toBe(false); + }); + + test("should return 400 if id is missing", async () => { + try { + await axios.get(`${TEST_URL}/internal/leafwatch/profile/haveUsedHey`, { + headers: getTestAuthHeaders() + }); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 401 if the id token is missing", async () => { + try { + await axios.get(`${TEST_URL}/internal/leafwatch/profile/haveUsedHey`, { + params: { id: TEST_LENS_ID } + }); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); + + test("should return 401 if the user is not staff", async () => { + try { + await axios.get(`${TEST_URL}/internal/leafwatch/profile/haveUsedHey`, { + params: { id: TEST_LENS_ID }, + headers: getTestAuthHeaders() + }); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); +}); diff --git a/apps/api/tests/internal/leafwatch/account/impressions.spec.ts b/apps/api/tests/internal/leafwatch/account/impressions.spec.ts new file mode 100644 index 000000000000..e5c153fd9ac7 --- /dev/null +++ b/apps/api/tests/internal/leafwatch/account/impressions.spec.ts @@ -0,0 +1,55 @@ +import { TEST_LENS_ID } from "@hey/data/constants"; +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import getTestAuthHeaders from "tests/helpers/getTestAuthHeaders"; +import { describe, expect, test } from "vitest"; + +describe("GET /internal/leafwatch/profile/impressions", () => { + test("should return 200 and valid impressions data", async () => { + const { data, status } = await axios.get( + `${TEST_URL}/internal/leafwatch/profile/impressions`, + { params: { id: TEST_LENS_ID }, headers: getTestAuthHeaders() } + ); + + expect(status).toBe(200); + expect(data.success).toBe(true); + expect(data.totalImpressions).toStrictEqual(expect.any(Number)); + expect(data.yearlyImpressions).toBeInstanceOf(Array); + expect(data.yearlyImpressions[0]).toHaveProperty("day", expect.any(Number)); + expect(data.yearlyImpressions[0]).toHaveProperty( + "impressions", + expect.any(Number) + ); + }); + + test("should return 400 if id is missing", async () => { + try { + await axios.get(`${TEST_URL}/internal/leafwatch/profile/impressions`, { + headers: getTestAuthHeaders() + }); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 401 if the user is not staff", async () => { + try { + await axios.get(`${TEST_URL}/internal/leafwatch/profile/impressions`, { + params: { id: TEST_LENS_ID }, + headers: getTestAuthHeaders("suspended") + }); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); + + test("should return 401 if the id token is missing", async () => { + try { + await axios.get(`${TEST_URL}/internal/leafwatch/profile/impressions`, { + params: { id: TEST_LENS_ID } + }); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); +}); diff --git a/apps/api/tests/internal/leafwatch/stats.spec.ts b/apps/api/tests/internal/leafwatch/stats.spec.ts new file mode 100644 index 000000000000..81a371db3a6e --- /dev/null +++ b/apps/api/tests/internal/leafwatch/stats.spec.ts @@ -0,0 +1,49 @@ +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import getTestAuthHeaders from "tests/helpers/getTestAuthHeaders"; +import { describe, expect, test } from "vitest"; + +describe("GET /internal/leafwatch/stats", () => { + test("should return 200 and valid stats", async () => { + const { data, status } = await axios.get( + `${TEST_URL}/internal/leafwatch/stats`, + { headers: getTestAuthHeaders() } + ); + + expect(status).toBe(200); + expect(data).toBeDefined(); + + expect(data.dau).toBeInstanceOf(Array); + expect(data.dau[0]).toHaveProperty("date", expect.any(String)); + expect(data.dau[0]).toHaveProperty("dau", expect.any(String)); + expect(data.dau[0]).toHaveProperty("events", expect.any(String)); + expect(data.dau[0]).toHaveProperty("impressions", expect.any(String)); + + expect(data.events).toHaveProperty("last_1_hour", expect.any(String)); + expect(data.events).toHaveProperty("today", expect.any(String)); + expect(data.impressions).toHaveProperty("last_1_hour", expect.any(String)); + expect(data.impressions).toHaveProperty("today", expect.any(String)); + + expect(data.referrers).toBeInstanceOf(Array); + expect(data.referrers[0]).toHaveProperty("referrer", expect.any(String)); + expect(data.referrers[0]).toHaveProperty("count", expect.any(String)); + }); + + test("should return 401 if the user is not staff", async () => { + try { + await axios.get(`${TEST_URL}/internal/leafwatch/stats`, { + headers: getTestAuthHeaders("suspended") + }); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); + + test("should return 401 if the id token is missing", async () => { + try { + await axios.get(`${TEST_URL}/internal/leafwatch/stats`); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); +}); diff --git a/apps/api/tests/internal/permissions/all.spec.ts b/apps/api/tests/internal/permissions/all.spec.ts new file mode 100644 index 000000000000..c0deb29c5c22 --- /dev/null +++ b/apps/api/tests/internal/permissions/all.spec.ts @@ -0,0 +1,56 @@ +import { faker } from "@faker-js/faker"; +import prisma from "@hey/db/prisma/db/client"; +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import getTestAuthHeaders from "tests/helpers/getTestAuthHeaders"; +import { beforeAll, describe, expect, test } from "vitest"; + +describe("GET /internal/permissions/all", () => { + let testPermissionKeys: string[]; + + beforeAll(async () => { + testPermissionKeys = [ + faker.string.uuid(), + faker.string.uuid(), + faker.string.uuid() + ]; + + await prisma.permission.createMany({ + data: testPermissionKeys.map((key) => ({ key, type: "PERMISSION" })) + }); + }); + + test("should return 200 and list all permissions", async () => { + const { data, status } = await axios.get( + `${TEST_URL}/internal/permissions/all`, + { headers: getTestAuthHeaders() } + ); + + expect(status).toBe(200); + expect(data.result).toBeDefined(); + expect(data.result).toBeInstanceOf(Array); + + const permissionKeys = data.result.map((p: any) => p.key); + for (const key of testPermissionKeys) { + expect(permissionKeys).toContain(key); + } + }); + + test("should return 401 if the user is not staff", async () => { + try { + await axios.get(`${TEST_URL}/internal/permissions/all`, { + headers: getTestAuthHeaders("suspended") + }); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); + + test("should return 401 if the id token is missing", async () => { + try { + await axios.get(`${TEST_URL}/internal/permissions/all`); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); +}); diff --git a/apps/api/tests/internal/permissions/assign.spec.ts b/apps/api/tests/internal/permissions/assign.spec.ts new file mode 100644 index 000000000000..9f1f61943bdb --- /dev/null +++ b/apps/api/tests/internal/permissions/assign.spec.ts @@ -0,0 +1,73 @@ +import { TEST_LENS_ID } from "@hey/data/constants"; +import { PermissionId } from "@hey/data/permissions"; +import prisma from "@hey/db/prisma/db/client"; +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import getTestAuthHeaders from "tests/helpers/getTestAuthHeaders"; +import { describe, expect, test } from "vitest"; + +describe("POST /internal/permissions/assign", () => { + test("should return 200 and enable the permission", async () => { + const { data, status } = await axios.post( + `${TEST_URL}/internal/permissions/assign`, + { accountId: TEST_LENS_ID, id: PermissionId.Beta, enabled: true }, + { headers: getTestAuthHeaders() } + ); + + expect(status).toBe(200); + expect(data.enabled).toBe(true); + + const accountPermission = await prisma.profilePermission.findFirst({ + where: { profileId: TEST_LENS_ID, permissionId: PermissionId.Beta } + }); + expect(accountPermission?.permissionId).toBe(PermissionId.Beta); + }); + + test("should return 200 and disable the permission", async () => { + const { data, status } = await axios.post( + `${TEST_URL}/internal/permissions/assign`, + { accountId: TEST_LENS_ID, id: PermissionId.Beta, enabled: false }, + { headers: getTestAuthHeaders() } + ); + + expect(status).toBe(200); + expect(data.enabled).toBe(false); + + const accountPermission = await prisma.profilePermission.findFirst({ + where: { profileId: TEST_LENS_ID, permissionId: PermissionId.Beta } + }); + expect(accountPermission).toBeNull(); + }); + + test("should return 400 if body is missing", async () => { + try { + await axios.post( + `${TEST_URL}/internal/permissions/assign`, + {}, + { headers: getTestAuthHeaders() } + ); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 401 if the user is not staff", async () => { + try { + await axios.post( + `${TEST_URL}/internal/permissions/assign`, + { accountId: TEST_LENS_ID, id: PermissionId.Beta, enabled: true }, + { headers: getTestAuthHeaders("suspended") } + ); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); + + test("should return 401 if the id token is missing", async () => { + try { + await axios.post(`${TEST_URL}/internal/permissions/assign`); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); +}); diff --git a/apps/api/tests/internal/permissions/bulkAssign.spec.ts b/apps/api/tests/internal/permissions/bulkAssign.spec.ts new file mode 100644 index 000000000000..7c4fd22c5987 --- /dev/null +++ b/apps/api/tests/internal/permissions/bulkAssign.spec.ts @@ -0,0 +1,79 @@ +import { faker } from "@faker-js/faker"; +import { TEST_LENS_ID } from "@hey/data/constants"; +import { PermissionId } from "@hey/data/permissions"; +import prisma from "@hey/db/prisma/db/client"; +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import getTestAuthHeaders from "tests/helpers/getTestAuthHeaders"; +import { describe, expect, test } from "vitest"; + +describe("POST /internal/permissions/bulkAssign", () => { + const testAccountIds = [ + faker.finance.ethereumAddress(), + faker.finance.ethereumAddress(), + faker.finance.ethereumAddress() + ]; + + test("should return 200 and bulk assign permissions", async () => { + const { data, status } = await axios.post( + `${TEST_URL}/internal/permissions/bulkAssign`, + { id: PermissionId.Beta, ids: JSON.stringify(testAccountIds) }, + { headers: getTestAuthHeaders() } + ); + + expect(status).toBe(200); + expect(data.assigned).toBe(testAccountIds.length); + + const accountPermissions = await prisma.profilePermission.findMany({ + where: { + permissionId: PermissionId.Beta, + profileId: { in: testAccountIds } + } + }); + + expect(accountPermissions.length).toBe(testAccountIds.length); + }); + + test("should return 200 and skip already assigned permissions", async () => { + const { data, status } = await axios.post( + `${TEST_URL}/internal/permissions/bulkAssign`, + { id: PermissionId.Verified, ids: JSON.stringify([TEST_LENS_ID]) }, + { headers: getTestAuthHeaders() } + ); + + expect(status).toBe(200); + expect(data.assigned).toBe(0); + }); + + test("should return 400 if body is missing", async () => { + try { + await axios.post( + `${TEST_URL}/internal/permissions/bulkAssign`, + {}, + { headers: getTestAuthHeaders() } + ); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 401 if the user is not staff", async () => { + try { + await axios.post( + `${TEST_URL}/internal/permissions/bulkAssign`, + { id: PermissionId.Beta, ids: JSON.stringify(testAccountIds) }, + { headers: getTestAuthHeaders("suspended") } + ); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); + + test("should return 401 if the id token is missing", async () => { + try { + await axios.post(`${TEST_URL}/internal/permissions/bulkAssign`); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); +}); diff --git a/apps/api/tests/internal/stats/overview.spec.ts b/apps/api/tests/internal/stats/overview.spec.ts new file mode 100644 index 000000000000..bcb6d8f2403d --- /dev/null +++ b/apps/api/tests/internal/stats/overview.spec.ts @@ -0,0 +1,39 @@ +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import getTestAuthHeaders from "tests/helpers/getTestAuthHeaders"; +import { describe, expect, test } from "vitest"; + +describe("GET /internal/stats/overview", () => { + test("should return 200 and overview stats", async () => { + const { data, status } = await axios.get( + `${TEST_URL}/internal/stats/overview`, + { headers: getTestAuthHeaders() } + ); + + expect(status).toBe(200); + expect(data.result).toBeDefined(); + expect(typeof data.result).toBe("object"); + expect(data.result).toHaveProperty("lists"); + expect(data.result).toHaveProperty("listProfiles"); + expect(data.result).toHaveProperty("pinnedLists"); + expect(data.result).toHaveProperty("profilePermissions"); + }); + + test("should return 401 if the user is not staff", async () => { + try { + await axios.get(`${TEST_URL}/internal/stats/overview`, { + headers: getTestAuthHeaders("suspended") + }); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); + + test("should return 401 if the id token is missing", async () => { + try { + await axios.get(`${TEST_URL}/internal/stats/overview`); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); +}); diff --git a/apps/api/tests/internal/tokens/create.spec.ts b/apps/api/tests/internal/tokens/create.spec.ts new file mode 100644 index 000000000000..70b6a667149f --- /dev/null +++ b/apps/api/tests/internal/tokens/create.spec.ts @@ -0,0 +1,77 @@ +import { faker } from "@faker-js/faker"; +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import getTestAuthHeaders from "tests/helpers/getTestAuthHeaders"; +import { describe, expect, test } from "vitest"; + +describe("POST /internal/tokens/create", () => { + test("should return 400 if body is missing", async () => { + try { + await axios.post( + `${TEST_URL}/internal/tokens/create`, + {}, + { headers: getTestAuthHeaders() } + ); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 400 for invalid Ethereum address", async () => { + try { + await axios.post( + `${TEST_URL}/internal/tokens/create`, + { + contractAddress: "invalidAddress", + decimals: 18, + name: "Test Token", + symbol: "TT" + }, + { headers: getTestAuthHeaders() } + ); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 200 and create a new token", async () => { + const contractAddress = faker.finance.ethereumAddress(); + const name = faker.commerce.productName(); + const symbol = faker.commerce.productAdjective(); + + const { data, status } = await axios.post( + `${TEST_URL}/internal/tokens/create`, + { + contractAddress, + decimals: 18, + name, + symbol + }, + { headers: getTestAuthHeaders() } + ); + + expect(status).toBe(200); + expect(data.result).toBeDefined(); + expect(data.result.contractAddress).toBe(contractAddress); + expect(data.result.decimals).toBe(18); + expect(data.result.name).toBe(name); + expect(data.result.symbol).toBe(symbol); + }); + + test("should return 401 if the user is not staff", async () => { + try { + await axios.post( + `${TEST_URL}/internal/tokens/create`, + { + contractAddress: "0x1234567890abcdef1234567890abcdef12345678", + decimals: 18, + name: "Test Token", + symbol: "TT" + }, + { headers: getTestAuthHeaders("suspended") } + ); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); +}); diff --git a/apps/api/tests/internal/tokens/delete.spec.ts b/apps/api/tests/internal/tokens/delete.spec.ts new file mode 100644 index 000000000000..a5c4e1ba801f --- /dev/null +++ b/apps/api/tests/internal/tokens/delete.spec.ts @@ -0,0 +1,63 @@ +import { faker } from "@faker-js/faker"; +import prisma from "@hey/db/prisma/db/client"; +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import getTestAuthHeaders from "tests/helpers/getTestAuthHeaders"; +import { describe, expect, test } from "vitest"; + +describe("POST /internal/tokens/delete", () => { + test("should return 400 if body is missing", async () => { + try { + await axios.post( + `${TEST_URL}/internal/tokens/delete`, + {}, + { headers: getTestAuthHeaders() } + ); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 400 for invalid body", async () => { + try { + await axios.post( + `${TEST_URL}/internal/tokens/delete`, + { randomField: "invalid" }, + { headers: getTestAuthHeaders() } + ); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 200 and delete the token", async () => { + const contractAddress = faker.finance.ethereumAddress(); + const name = faker.commerce.productName(); + const symbol = faker.commerce.productAdjective(); + + const { id } = await prisma.allowedToken.create({ + data: { name, symbol, contractAddress } + }); + + const { data, status } = await axios.post( + `${TEST_URL}/internal/tokens/delete`, + { id }, + { headers: getTestAuthHeaders() } + ); + + expect(status).toBe(200); + expect(data.success).toBe(true); + }); + + test("should return 401 if the user is not staff", async () => { + try { + await axios.post( + `${TEST_URL}/internal/tokens/delete`, + { id: "validTokenId" }, + { headers: getTestAuthHeaders("suspended") } + ); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); +}); diff --git a/apps/api/tests/leafwatch/events.spec.ts b/apps/api/tests/leafwatch/events.spec.ts new file mode 100644 index 000000000000..f7db3f4dafaf --- /dev/null +++ b/apps/api/tests/leafwatch/events.spec.ts @@ -0,0 +1,66 @@ +import { PAGEVIEW } from "@hey/data/tracking"; +import axios from "axios"; +import { describe, expect, test } from "vitest"; +import { TEST_URL } from "../helpers/constants"; + +describe("POST /leafwatch/events", () => { + test("should return 400 if no body is provided", async () => { + try { + await axios.post(`${TEST_URL}/leafwatch/events`, {}); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 400 if event name is missing", async () => { + try { + await axios.post(`${TEST_URL}/leafwatch/events`, { + events: [{ url: "https://example.com", properties: {} }] + }); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 400 if invalid event name is found", async () => { + try { + await axios.post(`${TEST_URL}/leafwatch/events`, { + events: [{ name: "InvalidEventName", url: "https://example.com" }] + }); + } catch (error: any) { + expect(error.response.status).toBe(400); + expect(error.response.data.error).toBe("Invalid event found!"); + } + }); + + test("should return 200 with single valid event", async () => { + const { data, status } = await axios.post(`${TEST_URL}/leafwatch/events`, { + events: [{ name: PAGEVIEW, url: "https://hey.xyz" }] + }); + + expect(status).toBe(200); + expect(data.queue).toBeDefined(); + }); + + test("should return 200 with multiple valid events", async () => { + const { data, status } = await axios.post(`${TEST_URL}/leafwatch/events`, { + events: [ + { name: PAGEVIEW, url: "https://hey.xyz" }, + { name: PAGEVIEW, url: "https://hey.xyz" } + ] + }); + + expect(status).toBe(200); + expect(data.queue).toBeDefined(); + }); + + test("should return 400 if event url is missing", async () => { + try { + await axios.post(`${TEST_URL}/leafwatch/events`, { + events: [{ name: PAGEVIEW }] + }); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); +}); diff --git a/apps/api/tests/leafwatch/impressions.spec.ts b/apps/api/tests/leafwatch/impressions.spec.ts new file mode 100644 index 000000000000..6e6328bd6dd1 --- /dev/null +++ b/apps/api/tests/leafwatch/impressions.spec.ts @@ -0,0 +1,39 @@ +import axios from "axios"; +import { describe, expect, test } from "vitest"; +import { TEST_URL } from "../helpers/constants"; + +describe("POST /leafwatch/impressions", () => { + test("should return 400 if no body is provided", async () => { + try { + await axios.post(`${TEST_URL}/leafwatch/impressions`, {}); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 400 for invalid body (non-array or empty ids)", async () => { + try { + await axios.post(`${TEST_URL}/leafwatch/impressions`, { + ids: "not-an-array" + }); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + + try { + await axios.post(`${TEST_URL}/leafwatch/impressions`, { ids: [] }); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 200 for valid body and ingest impressions", async () => { + const { data, status } = await axios.post( + `${TEST_URL}/leafwatch/impressions`, + { ids: ["0x0d-0x01", "0x0d-0x02", "0x0d-0x03"] } + ); + + expect(status).toBe(200); + expect(data.queue).toBeDefined(); + }); +}); diff --git a/apps/api/tests/lens/internal/stats/nft-revenue.spec.ts b/apps/api/tests/lens/internal/stats/nft-revenue.spec.ts new file mode 100644 index 000000000000..a7b18b08c8d8 --- /dev/null +++ b/apps/api/tests/lens/internal/stats/nft-revenue.spec.ts @@ -0,0 +1,37 @@ +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import getTestAuthHeaders from "tests/helpers/getTestAuthHeaders"; +import { describe, expect, test } from "vitest"; + +describe("GET /lens/internal/stats/nft-revenue", () => { + test("should return 200 and membership NFT revenue stats", async () => { + const { data, status } = await axios.get( + `${TEST_URL}/lens/internal/stats/nft-revenue`, + { headers: getTestAuthHeaders() } + ); + + expect(status).toBe(200); + expect(data.result).toBeDefined(); + expect(data.result).toBeInstanceOf(Array); + expect(data.result[0]).toHaveProperty("date"); + expect(data.result[0]).toHaveProperty("count", expect.any(Number)); + }); + + test("should return 401 if the user is not staff", async () => { + try { + await axios.get(`${TEST_URL}/lens/internal/stats/nft-revenue`, { + headers: getTestAuthHeaders("suspended") + }); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); + + test("should return 401 if the id token is missing", async () => { + try { + await axios.get(`${TEST_URL}/lens/internal/stats/nft-revenue`); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); +}); diff --git a/apps/api/tests/lens/internal/stats/overview.spec.ts b/apps/api/tests/lens/internal/stats/overview.spec.ts new file mode 100644 index 000000000000..5742d9a1ab87 --- /dev/null +++ b/apps/api/tests/lens/internal/stats/overview.spec.ts @@ -0,0 +1,39 @@ +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import getTestAuthHeaders from "tests/helpers/getTestAuthHeaders"; +import { describe, expect, test } from "vitest"; + +describe("GET /lens/internal/stats/overview", () => { + test("should return 200 and overview stats", async () => { + const { data, status } = await axios.get( + `${TEST_URL}/lens/internal/stats/overview`, + { headers: getTestAuthHeaders() } + ); + + expect(status).toBe(200); + expect(data.result).toBeDefined(); + expect(typeof data.result).toBe("object"); + expect(data.result).toHaveProperty("authenticationsCount"); + expect(data.result).toHaveProperty("relayUsageCount"); + expect(data.result).toHaveProperty("postsCount"); + expect(data.result).toHaveProperty("profilesCount"); + }); + + test("should return 401 if the user is not staff", async () => { + try { + await axios.get(`${TEST_URL}/lens/internal/stats/overview`, { + headers: getTestAuthHeaders("suspended") + }); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); + + test("should return 401 if the id token is missing", async () => { + try { + await axios.get(`${TEST_URL}/lens/internal/stats/overview`); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); +}); diff --git a/apps/api/tests/lens/internal/stats/signup-revenue.spec.ts b/apps/api/tests/lens/internal/stats/signup-revenue.spec.ts new file mode 100644 index 000000000000..6bdc924f5930 --- /dev/null +++ b/apps/api/tests/lens/internal/stats/signup-revenue.spec.ts @@ -0,0 +1,37 @@ +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import getTestAuthHeaders from "tests/helpers/getTestAuthHeaders"; +import { describe, expect, test } from "vitest"; + +describe("GET /lens/internal/stats/signup-revenue", () => { + test("should return 200 and valid signups data", async () => { + const { data, status } = await axios.get( + `${TEST_URL}/lens/internal/stats/signup-revenue`, + { headers: getTestAuthHeaders() } + ); + + expect(status).toBe(200); + expect(data).toBeDefined(); + expect(data.result).toBeInstanceOf(Array); + expect(data.result[0]).toHaveProperty("date", expect.any(String)); + expect(data.result[0]).toHaveProperty("count", expect.any(Number)); + }); + + test("should return 401 if the user is not staff", async () => { + try { + await axios.get(`${TEST_URL}/lens/internal/stats/signup-revenue`, { + headers: getTestAuthHeaders("suspended") + }); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); + + test("should return 401 if the id token is missing", async () => { + try { + await axios.get(`${TEST_URL}/lens/internal/stats/signup-revenue`); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); +}); diff --git a/apps/api/tests/lens/rate.spec.ts b/apps/api/tests/lens/rate.spec.ts new file mode 100644 index 000000000000..c2596a86aa0b --- /dev/null +++ b/apps/api/tests/lens/rate.spec.ts @@ -0,0 +1,22 @@ +import axios from "axios"; +import { describe, expect, test } from "vitest"; +import { TEST_URL } from "../helpers/constants"; + +describe("GET /lens/rate", () => { + test("should return 200 and valid rate and structure", async () => { + const { data, status } = await axios.get(`${TEST_URL}/lens/rate`); + + expect(status).toBe(200); + expect(data.result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + address: expect.any(String), + decimals: expect.any(Number), + fiat: expect.any(Number), + name: expect.any(String), + symbol: expect.any(String) + }) + ]) + ); + }); +}); diff --git a/apps/api/tests/lists/all.spec.ts b/apps/api/tests/lists/all.spec.ts new file mode 100644 index 000000000000..1bfbdcb8e89a --- /dev/null +++ b/apps/api/tests/lists/all.spec.ts @@ -0,0 +1,65 @@ +import { TEST_LENS_ID } from "@hey/data/constants"; +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import getTestAuthHeaders from "tests/helpers/getTestAuthHeaders"; +import { describe, expect, test } from "vitest"; + +describe("GET /lists/all", () => { + test("should return 400 if no ownerId is provided", async () => { + try { + await axios.get(`${TEST_URL}/lists/all`, { + headers: getTestAuthHeaders() + }); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 200 with a list of lists", async () => { + const { data, status } = await axios.get(`${TEST_URL}/lists/all`, { + params: { ownerId: TEST_LENS_ID } + }); + + expect(status).toBe(200); + expect(data.result).toBeDefined(); + expect(data.result.length).toBeGreaterThan(0); + expect(data.result[0].totalPins).toStrictEqual(expect.any(Number)); + expect(data.result[0].totalAccounts).toStrictEqual(expect.any(Number)); + }); + + test("should return 200 with isAdded as true if the user is added to the list", async () => { + const { data, status } = await axios.get(`${TEST_URL}/lists/all`, { + params: { ownerId: "0x0d", viewingId: TEST_LENS_ID } + }); + + expect(status).toBe(200); + expect(data.result[0].isAdded).toBe(true); + }); + + test("should return 200 with isAdded as false if the user is not added to the list", async () => { + const { data, status } = await axios.get(`${TEST_URL}/lists/all`, { + params: { ownerId: TEST_LENS_ID, viewingId: "0x0d" } + }); + + expect(status).toBe(200); + expect(data.result[0].isAdded).toBe(false); + }); + + test("should return 200 with pinned as true if the list is pinned", async () => { + const { data, status } = await axios.get(`${TEST_URL}/lists/all`, { + params: { ownerId: "0x0d" } + }); + + expect(status).toBe(200); + expect(data.result[0].pinned).toBe(true); + }); + + test("should return 200 with pinned as false if the list is not pinned", async () => { + const { data, status } = await axios.get(`${TEST_URL}/lists/all`, { + params: { ownerId: TEST_LENS_ID } + }); + + expect(status).toBe(200); + expect(data.result[0].pinned).toBe(false); + }); +}); diff --git a/apps/api/tests/lists/create.spec.ts b/apps/api/tests/lists/create.spec.ts new file mode 100644 index 000000000000..b8356cabc80f --- /dev/null +++ b/apps/api/tests/lists/create.spec.ts @@ -0,0 +1,78 @@ +import { faker } from "@faker-js/faker"; +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import getTestAuthHeaders from "tests/helpers/getTestAuthHeaders"; +import { describe, expect, test } from "vitest"; + +describe("POST /lists/create", () => { + test("should return 400 if body is missing", async () => { + try { + await axios.post( + `${TEST_URL}/lists/create`, + {}, + { headers: getTestAuthHeaders() } + ); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 400 for no name", async () => { + try { + await axios.post( + `${TEST_URL}/lists/create`, + { description: faker.lorem.sentence(), avatar: faker.image.url() }, + { headers: getTestAuthHeaders() } + ); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 200 and create a new list", async () => { + const name = faker.commerce.productName(); + const description = faker.lorem.sentence(); + const avatar = faker.image.url(); + + const { data, status } = await axios.post( + `${TEST_URL}/lists/create`, + { name, description, avatar }, + { headers: getTestAuthHeaders() } + ); + + expect(status).toBe(200); + expect(data.result).toBeDefined(); + expect(data.result.name).toBe(name); + expect(data.result.description).toBe(description); + expect(data.result.avatar).toBe(avatar); + }); + + test("should return 401 if the user is not staff", async () => { + try { + await axios.post( + `${TEST_URL}/internal/tokens/create`, + { + contractAddress: "0x1234567890abcdef1234567890abcdef12345678", + decimals: 18, + name: "Test Token", + symbol: "TT" + }, + { headers: getTestAuthHeaders("suspended") } + ); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); + + test("should return 401 if the id token is missing", async () => { + try { + await axios.post(`${TEST_URL}/lists/create`, { + name: faker.commerce.productName(), + description: faker.lorem.sentence(), + avatar: faker.image.url() + }); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); +}); diff --git a/apps/api/tests/lists/get.spec.ts b/apps/api/tests/lists/get.spec.ts new file mode 100644 index 000000000000..c9448bfac709 --- /dev/null +++ b/apps/api/tests/lists/get.spec.ts @@ -0,0 +1,42 @@ +import { faker } from "@faker-js/faker"; +import { TEST_LENS_ID } from "@hey/data/constants"; +import prisma from "@hey/db/prisma/db/client"; +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import getTestAuthHeaders from "tests/helpers/getTestAuthHeaders"; +import { describe, expect, test } from "vitest"; + +describe("GET /lists/get", () => { + test("should return 400 if no id is provided", async () => { + try { + await axios.get(`${TEST_URL}/lists/get`, { + headers: getTestAuthHeaders() + }); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 200 with a list", async () => { + const newList = await prisma.list.create({ + data: { + name: faker.commerce.productName(), + description: faker.lorem.sentence(), + avatar: faker.image.url(), + createdBy: TEST_LENS_ID + } + }); + + const { data, status } = await axios.get(`${TEST_URL}/lists/get`, { + params: { id: newList.id } + }); + + expect(status).toBe(200); + expect(data.result).toBeDefined(); + expect(data.result.name).toBe(newList.name); + expect(data.result.description).toBe(newList.description); + expect(data.result.avatar).toBe(newList.avatar); + expect(data.result.totalPins).toStrictEqual(expect.any(Number)); + expect(data.result.totalAccounts).toStrictEqual(expect.any(Number)); + }); +}); diff --git a/apps/api/tests/lists/pinned.spec.ts b/apps/api/tests/lists/pinned.spec.ts new file mode 100644 index 000000000000..b7d860bd5d00 --- /dev/null +++ b/apps/api/tests/lists/pinned.spec.ts @@ -0,0 +1,27 @@ +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import getTestAuthHeaders from "tests/helpers/getTestAuthHeaders"; +import { describe, expect, test } from "vitest"; + +describe("GET /lists/pinned", () => { + test("should return 200 with a list of pinned lists", async () => { + const { data, status } = await axios.get(`${TEST_URL}/lists/pinned`, { + headers: getTestAuthHeaders() + }); + + expect(status).toBe(200); + expect(data.result).toBeDefined(); + expect(data.result.length).toBeGreaterThan(0); + expect(data.result[0].totalPins).toStrictEqual(expect.any(Number)); + expect(data.result[0].totalAccounts).toStrictEqual(expect.any(Number)); + expect(data.result[0].pinned).toBe(true); + }); + + test("should return 401 if the id token is missing", async () => { + try { + await axios.get(`${TEST_URL}/lists/pinned`); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); +}); diff --git a/apps/api/tests/lists/posts.spec.ts b/apps/api/tests/lists/posts.spec.ts new file mode 100644 index 000000000000..4c29d44183cf --- /dev/null +++ b/apps/api/tests/lists/posts.spec.ts @@ -0,0 +1,35 @@ +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import getTestAuthHeaders from "tests/helpers/getTestAuthHeaders"; +import { describe, expect, test } from "vitest"; + +describe("GET /lists/posts", () => { + test("should return 400 if no id is provided", async () => { + try { + await axios.get(`${TEST_URL}/lists/posts`, { + headers: getTestAuthHeaders() + }); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 200 with a list's profile's publications", async () => { + const { data, status } = await axios.get(`${TEST_URL}/lists/posts`, { + params: { id: "0c34a529-8db6-40b8-9b35-7f474f7d509a" } + }); + + expect(status).toBe(200); + expect(data.result).toHaveLength(50); + }); + + test("should return 200 with a list's profile's posts with pagination", async () => { + const { data, status } = await axios.get(`${TEST_URL}/lists/posts`, { + params: { id: "0c34a529-8db6-40b8-9b35-7f474f7d509a", page: 2 } + }); + + expect(status).toBe(200); + expect(data.result).toHaveLength(50); + expect(data.offset).toBe(50); + }); +}); diff --git a/apps/api/tests/live/create.spec.ts b/apps/api/tests/live/create.spec.ts new file mode 100644 index 000000000000..7423f8bc0844 --- /dev/null +++ b/apps/api/tests/live/create.spec.ts @@ -0,0 +1,46 @@ +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import getTestAuthHeaders from "tests/helpers/getTestAuthHeaders"; +import { describe, expect, test } from "vitest"; + +describe("POST /live/create", () => { + test("should return 400 if no body is provided", async () => { + try { + await axios.post( + `${TEST_URL}/live/create`, + {}, + { headers: getTestAuthHeaders() } + ); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 200 and create live stream with recording enabled", async () => { + const { data, status } = await axios.post( + `${TEST_URL}/live/create`, + { record: true }, + { headers: getTestAuthHeaders() } + ); + + expect(status).toBe(200); + expect(data.result).toMatchObject({ + record: true, + createdByTokenName: "Hey Live" + }); + }); + + test("should return 200 and create live stream with recording disabled", async () => { + const { data, status } = await axios.post( + `${TEST_URL}/live/create`, + { record: false }, + { headers: getTestAuthHeaders() } + ); + + expect(status).toBe(200); + expect(data.result).toMatchObject({ + record: false, + createdByTokenName: "Hey Live" + }); + }); +}); diff --git a/apps/api/tests/meta.spec.ts b/apps/api/tests/meta.spec.ts new file mode 100644 index 000000000000..7562a5c271b8 --- /dev/null +++ b/apps/api/tests/meta.spec.ts @@ -0,0 +1,25 @@ +import axios from "axios"; +import { describe, expect, test } from "vitest"; +import { TEST_URL } from "./helpers/constants"; + +describe("GET /meta", () => { + test("should respond with status 200 and correct meta information", async () => { + const { data, status } = await axios.get(`${TEST_URL}/meta`); + + expect(status).toBe(200); + expect(data).toEqual({ + meta: { + deployment: "unknown", + replica: "unknown", + snapshot: "unknown" + }, + responseTimes: expect.objectContaining({ + clickhouse: expect.any(String), + hey: expect.any(String), + lens: expect.any(String), + redis: expect.any(String), + unleash: expect.any(String) + }) + }); + }); +}); diff --git a/apps/api/tests/metadata/index.spec.ts b/apps/api/tests/metadata/index.spec.ts new file mode 100644 index 000000000000..63414be82e4a --- /dev/null +++ b/apps/api/tests/metadata/index.spec.ts @@ -0,0 +1,30 @@ +import axios from "axios"; +import { describe, expect, test } from "vitest"; +import { TEST_URL } from "../helpers/constants"; + +describe("POST /metadata", () => { + test("should return 400 if no body is provided", async () => { + try { + await axios.post(`${TEST_URL}/metadata`); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should upload signed metadata to S3 and return success", async () => { + const metadata = { + $schema: "https://json-schemas.lens.dev/profile/2.0.0.json", + lens: { + id: "ee5755ad-6655-4327-a5ef-c57b85855f33", + name: "Yoginth" + } + }; + + const { data, status } = await axios.post(`${TEST_URL}/metadata`, metadata); + + expect(status).toBe(200); + expect(data.id).toMatch( + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[4][0-9a-fA-F]{3}-[89aAbB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/ + ); + }); +}); diff --git a/apps/api/tests/misc/verified.spec.ts b/apps/api/tests/misc/verified.spec.ts new file mode 100644 index 000000000000..55bd92fe2451 --- /dev/null +++ b/apps/api/tests/misc/verified.spec.ts @@ -0,0 +1,14 @@ +import { TEST_LENS_ID } from "@hey/data/constants"; +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import { describe, expect, test } from "vitest"; + +describe("GET /misc/verified", () => { + test("should return 200 and fetch the verified accounts", async () => { + const { data, status } = await axios.get(`${TEST_URL}/misc/verified`); + + expect(status).toBe(200); + expect(data.result).toBeInstanceOf(Array); + expect(data.result).contains(TEST_LENS_ID); + }); +}); diff --git a/apps/api/tests/oembed/index.spec.ts b/apps/api/tests/oembed/index.spec.ts new file mode 100644 index 000000000000..4a8e434624ab --- /dev/null +++ b/apps/api/tests/oembed/index.spec.ts @@ -0,0 +1,106 @@ +import { HEY_IMAGEKIT_URL } from "@hey/data/constants"; +import axios from "axios"; +import { describe, expect, test } from "vitest"; +import { TEST_URL } from "../helpers/constants"; + +describe("GET /oembed", () => { + test("should return 400 if no url is provided", async () => { + try { + await axios.get(`${TEST_URL}/oembed`); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return oembed data if the url is valid", async () => { + const { data, status } = await axios.get(`${TEST_URL}/oembed`, { + params: { url: "https://github.com/bigint" } + }); + + expect(status).toBe(200); + expect(data.oembed).toMatchObject({ + image: `${HEY_IMAGEKIT_URL}/oembed/tr:di-placeholder.webp,h-400,w-400/https://avatars.githubusercontent.com/u/69431456?v=4?s=400`, + site: "GitHub", + title: "bigint - Overview", + url: "https://github.com/bigint" + }); + }); + + test("should return oembed data with HTML", async () => { + const { data, status } = await axios.get(`${TEST_URL}/oembed`, { + params: { url: "https://open.spotify.com/track/4ZnkygoWLzcGbQYCm3lkae" } + }); + + expect(status).toBe(200); + expect(data.oembed).toMatchObject({ + html: '' + }); + }); + + test("should return oembed data with Frame", async () => { + const { data, status } = await axios.get(`${TEST_URL}/oembed`, { + params: { + url: "https://zora.co/collect/zora:0xf2086c0eaa8b34b0eef73920d0b1b53f4146e2e4/1" + } + }); + + expect(status).toBe(200); + expect(data.oembed.frame).toMatchObject({ + acceptsAnonymous: true, + acceptsLens: false, + buttons: [ + { + action: "tx", + button: "Mint", + postUrl: + "https://zora.co/collect/zora:0xf2086c0eaa8b34b0eef73920d0b1b53f4146e2e4/1", + target: + "https://zora.co/api/frame/zora:0xf2086c0eaa8b34b0eef73920d0b1b53f4146e2e4/1" + }, + { + action: "link", + button: "View on Zora", + postUrl: + "https://zora.co/collect/zora:0xf2086c0eaa8b34b0eef73920d0b1b53f4146e2e4/1", + target: + "https://zora.co/collect/zora:0xf2086c0eaa8b34b0eef73920d0b1b53f4146e2e4/1" + } + ], + frameUrl: + "https://zora.co/collect/zora:0xf2086c0eaa8b34b0eef73920d0b1b53f4146e2e4/1", + image: + "https://zora.co/api/og-image/post/zora:0xf2086c0eaa8b34b0eef73920d0b1b53f4146e2e4/1?v=2", + openFramesVersion: "vNext", + postUrl: + "https://zora.co/collect/zora:0xf2086c0eaa8b34b0eef73920d0b1b53f4146e2e4/1" + }); + }); + + test("should return oembed data with NFT", async () => { + const { data, status } = await axios.get(`${TEST_URL}/oembed`, { + params: { + url: "https://pods.media/ufo/interpreting-technology-with-aixdesign-nadia-piet" + } + }); + + expect(status).toBe(200); + expect(data.oembed.nft).toMatchObject({ + chain: "base", + collectionName: "UFO", + contractAddress: "0x7dcc6d7468362e6c20f7170abe9a949cf1e256f7", + creatorAddress: "0x92f551665c69586fd5f30e6efdb78ac882b22d17", + description: + "Collect this episode to support UFO and secure your spot on the leaderboard!", + endTime: null, + mediaUrl: + "https://pods.media/api/og/frame/ufo/interpreting-technology-with-aixdesign-nadia-piet", + mintCount: expect.any(String), + mintStatus: null, + mintUrl: + "https://pods.media/ufo/interpreting-technology-with-aixdesign-nadia-piet", + schema: "erc1155", + sourceUrl: + "https://pods.media/ufo/interpreting-technology-with-aixdesign-nadia-piet" + }); + }); +}); diff --git a/apps/api/tests/polls/act.spec.ts b/apps/api/tests/polls/act.spec.ts new file mode 100644 index 000000000000..d41688fcf418 --- /dev/null +++ b/apps/api/tests/polls/act.spec.ts @@ -0,0 +1,134 @@ +import { faker } from "@faker-js/faker"; +import prisma from "@hey/db/prisma/db/client"; +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import getTestAuthHeaders from "tests/helpers/getTestAuthHeaders"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; + +describe("POST /polls/act", () => { + let testPollId: string; + let testOptionId: string; + + beforeAll(async () => { + const poll = await prisma.poll.create({ + data: { + endsAt: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000), + options: { + createMany: { + data: [ + { option: faker.lorem.word(), index: 0 }, + { option: faker.lorem.word(), index: 1 } + ] + } + } + }, + include: { options: true } + }); + testPollId = poll.id; + testOptionId = poll.options[0].id; + }); + + test("should return 400 if body is missing", async () => { + try { + await axios.post( + `${TEST_URL}/polls/act`, + {}, + { headers: getTestAuthHeaders() } + ); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 400 if the poll has expired", async () => { + const expiredPoll = await prisma.poll.create({ + data: { + endsAt: new Date(Date.now() - 24 * 60 * 60 * 1000), + options: { + createMany: { + data: [ + { option: faker.lorem.word(), index: 0 }, + { option: faker.lorem.word(), index: 1 } + ] + } + } + }, + include: { options: true } + }); + + try { + await axios.post( + `${TEST_URL}/polls/act`, + { poll: expiredPoll.id, option: expiredPoll.options[0].id }, + { headers: getTestAuthHeaders() } + ); + } catch (error: any) { + expect(error.response.status).toBe(400); + expect(error.response.data.error).toBe("Poll expired."); + } + }); + + test("should allow voting in an active poll and return 200", async () => { + const { data, status } = await axios.post( + `${TEST_URL}/polls/act`, + { poll: testPollId, option: testOptionId }, + { headers: getTestAuthHeaders() } + ); + + expect(status).toBe(200); + expect(data.id).toBeDefined(); + expect(data.success).toBe(true); + + const pollResponse = await prisma.pollResponse.findFirst({ + where: { optionId: testOptionId } + }); + expect(pollResponse).not.toBeNull(); + }); + + test("should return 401 if the id token is missing", async () => { + try { + await axios.post(`${TEST_URL}/polls/act`, { + poll: testPollId, + option: testOptionId + }); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); + + test("should delete existing response and allow revote", async () => { + await axios.post( + `${TEST_URL}/polls/act`, + { poll: testPollId, option: testOptionId }, + { headers: getTestAuthHeaders() } + ); + + const newOptionId = ( + await prisma.pollOption.findFirst({ + where: { pollId: testPollId, index: 1 } + }) + )?.id; + + const { data, status } = await axios.post( + `${TEST_URL}/polls/act`, + { poll: testPollId, option: newOptionId }, + { headers: getTestAuthHeaders() } + ); + + expect(status).toBe(200); + expect(data.id).toBeDefined(); + expect(data.success).toBe(true); + + const pollResponses = await prisma.pollResponse.findMany({ + where: { option: { pollId: testPollId } } + }); + expect(pollResponses).toHaveLength(1); + expect(pollResponses[0]?.optionId).toBe(newOptionId); + }); + + afterAll(async () => { + await prisma.pollResponse.deleteMany({}); + await prisma.pollOption.deleteMany({ where: { pollId: testPollId } }); + await prisma.poll.delete({ where: { id: testPollId } }); + }); +}); diff --git a/apps/api/tests/polls/create.spec.ts b/apps/api/tests/polls/create.spec.ts new file mode 100644 index 000000000000..55d0109c2595 --- /dev/null +++ b/apps/api/tests/polls/create.spec.ts @@ -0,0 +1,81 @@ +import { faker } from "@faker-js/faker"; +import prisma from "@hey/db/prisma/db/client"; +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import getTestAuthHeaders from "tests/helpers/getTestAuthHeaders"; +import { describe, expect, test } from "vitest"; + +describe("POST /polls/create", () => { + test("should return 400 if body is missing", async () => { + try { + await axios.post( + `${TEST_URL}/polls/create`, + {}, + { headers: getTestAuthHeaders() } + ); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 400 for invalid poll length", async () => { + try { + await axios.post( + `${TEST_URL}/polls/create`, + { length: 31, options: ["Option 1", "Option 2"] }, + { headers: getTestAuthHeaders() } + ); + } catch (error: any) { + expect(error.response.status).toBe(400); + expect(error.response.data.error).toBe( + "Poll length should be between 1 and 30 days." + ); + } + }); + + test("should return 400 for missing options", async () => { + try { + await axios.post( + `${TEST_URL}/polls/create`, + { length: 5, options: [] }, + { headers: getTestAuthHeaders() } + ); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should create a poll and return 200", async () => { + const options = [faker.lorem.word(), faker.lorem.word()]; + + const { data, status } = await axios.post( + `${TEST_URL}/polls/create`, + { length: 5, options }, + { headers: getTestAuthHeaders() } + ); + + expect(status).toBe(200); + expect(data.result).toBeDefined(); + expect(data.result.id).toBeDefined(); + expect(data.result.options).toBeInstanceOf(Array); + expect(data.result.options).toHaveLength(options.length); + + const poll = await prisma.poll.findUnique({ + where: { id: data.result.id }, + include: { options: true } + }); + expect(poll).not.toBeNull(); + expect(poll?.options).toHaveLength(options.length); + }); + + test("should return 401 if the id token is missing", async () => { + try { + await axios.post(`${TEST_URL}/polls/create`, { + length: 5, + options: ["Option 1", "Option 2"] + }); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); +}); diff --git a/apps/api/tests/polls/get.spec.ts b/apps/api/tests/polls/get.spec.ts new file mode 100644 index 000000000000..4a210dbfe157 --- /dev/null +++ b/apps/api/tests/polls/get.spec.ts @@ -0,0 +1,85 @@ +import { faker } from "@faker-js/faker"; +import prisma from "@hey/db/prisma/db/client"; +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import getTestAuthHeaders from "tests/helpers/getTestAuthHeaders"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; + +describe("GET /polls/get", () => { + let testPollId: string; + + beforeAll(async () => { + const poll = await prisma.poll.create({ + data: { + endsAt: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000), + options: { + createMany: { + data: [ + { option: "Option 1", index: 0 }, + { option: "Option 2", index: 1 } + ] + } + } + }, + include: { options: true } + }); + testPollId = poll.id; + }); + + test("should return 400 if poll id is missing", async () => { + try { + await axios.get(`${TEST_URL}/polls/get`, { + headers: getTestAuthHeaders() + }); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 404 for non-existent poll", async () => { + try { + await axios.get(`${TEST_URL}/polls/get`, { + headers: getTestAuthHeaders(), + params: { id: faker.string.uuid() } + }); + } catch (error: any) { + expect(error.response.status).toBe(404); + expect(error.response.data.error).toBe("Not found!"); + } + }); + + test("should return poll data for a valid poll id", async () => { + const { data, status } = await axios.get(`${TEST_URL}/polls/get`, { + headers: getTestAuthHeaders(), + params: { id: testPollId } + }); + + expect(status).toBe(200); + expect(data.result).toBeDefined(); + expect(data.result.id).toBe(testPollId); + expect(data.result.options).toHaveLength(2); + expect(data.result.options[0].option).toBe("Option 1"); + + expect(data.result.options[0]).toHaveProperty("id"); + expect(data.result.options[0]).toHaveProperty("option"); + expect(data.result.options[0]).toHaveProperty("percentage"); + expect(data.result.options[0]).toHaveProperty("responses"); + expect(data.result.options[0]).toHaveProperty("voted"); + }); + + test("should return 401 if the id token is missing", async () => { + try { + await axios.get(`${TEST_URL}/polls/get`, { + params: { id: testPollId }, + headers: {} + }); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); + + afterAll(async () => { + await prisma.pollOption.deleteMany({ where: { pollId: testPollId } }); + await prisma.poll.delete({ where: { id: testPollId } }); + }); +}); diff --git a/apps/api/tests/preferences/get.spec.ts b/apps/api/tests/preferences/get.spec.ts new file mode 100644 index 000000000000..4adb00536036 --- /dev/null +++ b/apps/api/tests/preferences/get.spec.ts @@ -0,0 +1,76 @@ +import { faker } from "@faker-js/faker"; +import { TEST_LENS_ID } from "@hey/data/constants"; +import prisma from "@hey/db/prisma/db/client"; +import { delRedis } from "@hey/db/redisClient"; +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import getTestAuthHeaders from "tests/helpers/getTestAuthHeaders"; +import { beforeAll, describe, expect, test } from "vitest"; + +describe("GET /preferences/get", () => { + let testEmail: string; + let testPermissionKey: string; + + beforeAll(async () => { + testEmail = faker.internet.email(); + testPermissionKey = faker.string.uuid(); + + const [, , , permission] = await Promise.all([ + prisma.preference.upsert({ + where: { id: TEST_LENS_ID }, + update: { + appIcon: 2, + highSignalNotificationFilter: true, + developerMode: true + }, + create: { + id: TEST_LENS_ID, + appIcon: 2, + highSignalNotificationFilter: true, + developerMode: true + } + }), + prisma.email.upsert({ + where: { id: TEST_LENS_ID }, + update: { email: testEmail, verified: true }, + create: { id: TEST_LENS_ID, email: testEmail, verified: true } + }), + prisma.membershipNft.upsert({ + where: { id: TEST_LENS_ID }, + update: { dismissedOrMinted: true }, + create: { id: TEST_LENS_ID, dismissedOrMinted: true } + }), + prisma.permission.create({ data: { key: testPermissionKey } }) + ]); + + await prisma.profilePermission.create({ + data: { profileId: TEST_LENS_ID, permissionId: permission.id } + }); + }); + + test("should return 200 and profile preferences", async () => { + await delRedis(`preference:${TEST_LENS_ID}`); + + const { data, status } = await axios.get(`${TEST_URL}/preferences/get`, { + headers: getTestAuthHeaders() + }); + + expect(status).toBe(200); + expect(data.result).toBeDefined(); + expect(data.result.email).toBe(testEmail); + expect(data.result.emailVerified).toBe(true); + expect(data.result.appIcon).toBe(2); + expect(data.result.permissions).toContain(testPermissionKey); + expect(data.result.hasDismissedOrMintedMembershipNft).toBe(true); + expect(data.result.highSignalNotificationFilter).toBe(true); + expect(data.result.developerMode).toBe(true); + }); + + test("should return 401 if the id token is missing", async () => { + try { + await axios.get(`${TEST_URL}/preferences/get`); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); +}); diff --git a/apps/api/tests/preferences/theme/update.spec.ts b/apps/api/tests/preferences/theme/update.spec.ts new file mode 100644 index 000000000000..ea4c5d460b64 --- /dev/null +++ b/apps/api/tests/preferences/theme/update.spec.ts @@ -0,0 +1,42 @@ +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import getTestAuthHeaders from "tests/helpers/getTestAuthHeaders"; +import { describe, expect, test } from "vitest"; + +describe("POST /preferences/theme/update", () => { + test("should return 400 if no body is provided", async () => { + try { + await axios.post( + `${TEST_URL}/preferences/theme/update`, + {}, + { headers: getTestAuthHeaders() } + ); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 400 for invalid body (missing required fields)", async () => { + try { + await axios.post( + `${TEST_URL}/preferences/theme/update`, + { randomField: "invalid" }, + { headers: getTestAuthHeaders() } + ); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 200 and update the profile theme", async () => { + const { data, status } = await axios.post( + `${TEST_URL}/preferences/theme/update`, + { fontStyle: "bioRhyme" }, + { headers: getTestAuthHeaders() } + ); + + expect(status).toBe(200); + expect(data.result).toBeDefined(); + expect(data.result.fontStyle).toBe("bioRhyme"); + }); +}); diff --git a/apps/api/tests/preferences/update.spec.ts b/apps/api/tests/preferences/update.spec.ts new file mode 100644 index 000000000000..5f7f2ce1132c --- /dev/null +++ b/apps/api/tests/preferences/update.spec.ts @@ -0,0 +1,70 @@ +import { faker } from "@faker-js/faker"; +import { TEST_LENS_ID } from "@hey/data/constants"; +import { delRedis } from "@hey/db/redisClient"; +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import getTestAuthHeaders from "tests/helpers/getTestAuthHeaders"; +import { describe, expect, test } from "vitest"; + +describe("POST /preferences/update", () => { + test("should return 200 and update preferences", async () => { + const newAppIcon = faker.number.int({ min: 1, max: 10 }); + const highSignalNotificationFilter = faker.datatype.boolean(); + const developerMode = faker.datatype.boolean(); + + await delRedis(`preference:${TEST_LENS_ID}`); + + const { data, status } = await axios.post( + `${TEST_URL}/preferences/update`, + { + appIcon: newAppIcon, + highSignalNotificationFilter, + developerMode + }, + { headers: getTestAuthHeaders() } + ); + + expect(status).toBe(200); + expect(data.result).toBeDefined(); + expect(data.result.appIcon).toBe(newAppIcon); + expect(data.result.highSignalNotificationFilter).toBe( + highSignalNotificationFilter + ); + expect(data.result.developerMode).toBe(developerMode); + }); + + test("should return 400 if the request body is missing", async () => { + try { + await axios.post( + `${TEST_URL}/preferences/update`, + {}, + { headers: getTestAuthHeaders() } + ); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 400 for invalid request data", async () => { + try { + await axios.post( + `${TEST_URL}/preferences/update`, + { appIcon: "invalid", highSignalNotificationFilter: "invalid" }, + { headers: getTestAuthHeaders() } + ); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 401 if the id token is missing", async () => { + try { + await axios.post(`${TEST_URL}/preferences/update`, { + appIcon: 3, + highSignalNotificationFilter: true + }); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); +}); diff --git a/apps/api/tests/preferences/updateNftStatus.spec.ts b/apps/api/tests/preferences/updateNftStatus.spec.ts new file mode 100644 index 000000000000..15e2861d6a1e --- /dev/null +++ b/apps/api/tests/preferences/updateNftStatus.spec.ts @@ -0,0 +1,36 @@ +import { TEST_LENS_ID } from "@hey/data/constants"; +import prisma from "@hey/db/prisma/db/client"; +import { delRedis } from "@hey/db/redisClient"; +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import getTestAuthHeaders from "tests/helpers/getTestAuthHeaders"; +import { describe, expect, test } from "vitest"; + +describe("POST /preferences/updateNftStatus", () => { + test("should return 200 and update NFT status", async () => { + await delRedis(`preference:${TEST_LENS_ID}`); + + const { data, status } = await axios.post( + `${TEST_URL}/preferences/updateNftStatus`, + {}, + { headers: getTestAuthHeaders() } + ); + + expect(status).toBe(200); + expect(data.result).toBeDefined(); + expect(data.result.dismissedOrMinted).toBe(true); + + const membershipNft = await prisma.membershipNft.findUnique({ + where: { id: TEST_LENS_ID } + }); + expect(membershipNft?.dismissedOrMinted).toBe(true); + }); + + test("should return 401 if the id token is missing", async () => { + try { + await axios.post(`${TEST_URL}/preferences/updateNftStatus`); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); +}); diff --git a/apps/api/tests/sitemap/accounts.xml.spec.ts b/apps/api/tests/sitemap/accounts.xml.spec.ts new file mode 100644 index 000000000000..6e84c14ac012 --- /dev/null +++ b/apps/api/tests/sitemap/accounts.xml.spec.ts @@ -0,0 +1,37 @@ +import lensPg from "@hey/db/lensPg"; +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import { describe, expect, test, vi } from "vitest"; + +describe("GET /sitemap/accounts.xml", () => { + test("should return 200 and generate a sitemap with account links", async () => { + const mockCount = 1000; + const SITEMAP_BATCH_SIZE = 100; + vi.spyOn(lensPg, "query").mockResolvedValueOnce([{ count: mockCount }]); + + const { data, status, headers } = await axios.get( + `${TEST_URL}/sitemap/accounts.xml` + ); + + expect(status).toBe(200); + expect(headers["content-type"]).toBe("text/xml; charset=utf-8"); + + const totalBatches = Math.ceil(mockCount / SITEMAP_BATCH_SIZE); + + for (let i = 1; i <= totalBatches; i++) { + expect(data).toContain( + `https://api.hey.xyz/sitemap/accounts/${i}.xml` + ); + } + }); + + test("should return 500 if an error occurs", async () => { + vi.spyOn(lensPg, "query").mockRejectedValueOnce(new Error("DB Error")); + + try { + await axios.get(`${TEST_URL}/sitemap/accounts.xml`); + } catch (error: any) { + expect(error.response.status).toBe(500); + } + }); +}); diff --git a/apps/api/tests/sitemap/posts.xml.spec.ts b/apps/api/tests/sitemap/posts.xml.spec.ts new file mode 100644 index 000000000000..a37127111378 --- /dev/null +++ b/apps/api/tests/sitemap/posts.xml.spec.ts @@ -0,0 +1,37 @@ +import lensPg from "@hey/db/lensPg"; +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import { describe, expect, test, vi } from "vitest"; + +describe("GET /sitemap/posts.xml", () => { + test("should return 200 and generate a sitemap with post links", async () => { + const mockCount = 1000; + const SITEMAP_BATCH_SIZE = 100; + vi.spyOn(lensPg, "query").mockResolvedValueOnce([{ count: mockCount }]); + + const { data, status, headers } = await axios.get( + `${TEST_URL}/sitemap/posts.xml` + ); + + expect(status).toBe(200); + expect(headers["content-type"]).toBe("text/xml; charset=utf-8"); + + const totalBatches = Math.ceil(mockCount / SITEMAP_BATCH_SIZE); + + for (let i = 1; i <= totalBatches; i++) { + expect(data).toContain( + `https://api.hey.xyz/sitemap/posts/${i}.xml` + ); + } + }); + + test("should return 500 if an error occurs", async () => { + vi.spyOn(lensPg, "query").mockRejectedValueOnce(new Error("DB Error")); + + try { + await axios.get(`${TEST_URL}/sitemap/posts.xml`); + } catch (error: any) { + expect(error.response.status).toBe(500); + } + }); +}); diff --git a/apps/api/tests/staff-picks/index.spec.ts b/apps/api/tests/staff-picks/index.spec.ts new file mode 100644 index 000000000000..270ddd287629 --- /dev/null +++ b/apps/api/tests/staff-picks/index.spec.ts @@ -0,0 +1,26 @@ +import { TEST_LENS_ID } from "@hey/data/constants"; +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import { describe, expect, test } from "vitest"; + +describe("GET /staff-picks", () => { + test("should return 200 and provide staff picks", async () => { + const { data, status } = await axios.get(`${TEST_URL}/staff-picks`); + + expect(status).toBe(200); + expect(data.result).toBeInstanceOf(Array); + expect(data.result.length).toBeLessThanOrEqual(150); + }); + + test("should return valid staff picks data", async () => { + const { data, status } = await axios.get(`${TEST_URL}/staff-picks`); + + expect(status).toBe(200); + expect(data.result).toBeInstanceOf(Array); + + const hasAccountId = data.result.some( + (item: any) => item.profileId === TEST_LENS_ID + ); + expect(hasAccountId).toBe(true); + }); +}); diff --git a/apps/api/tests/sts/token.spec.ts b/apps/api/tests/sts/token.spec.ts new file mode 100644 index 000000000000..ecfad23dc82f --- /dev/null +++ b/apps/api/tests/sts/token.spec.ts @@ -0,0 +1,15 @@ +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import { describe, expect, test } from "vitest"; + +describe("GET /sts/token", () => { + test("should return 200 and provide temporary STS credentials", async () => { + const { data, status } = await axios.get(`${TEST_URL}/sts/token`); + + expect(status).toBe(200); + expect(data.success).toBe(true); + expect(data.accessKeyId).toBeDefined(); + expect(data.secretAccessKey).toBeDefined(); + expect(data.sessionToken).toBeDefined(); + }); +}); diff --git a/apps/api/tests/tips/create.spec.ts b/apps/api/tests/tips/create.spec.ts new file mode 100644 index 000000000000..4dfd32c1a181 --- /dev/null +++ b/apps/api/tests/tips/create.spec.ts @@ -0,0 +1,82 @@ +import crypto from "node:crypto"; + +import { faker } from "@faker-js/faker"; +import prisma from "@hey/db/prisma/db/client"; +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import getTestAuthHeaders from "tests/helpers/getTestAuthHeaders"; +import { describe, expect, test } from "vitest"; + +describe("POST /tips/create", () => { + test("should return 200 and create a new tip", async () => { + const payload = { + amount: faker.number.int({ min: 1, max: 100 }), + fromAddress: faker.finance.ethereumAddress(), + id: "0x1234-0x5678", + toAddress: faker.finance.ethereumAddress(), + tokenAddress: faker.finance.ethereumAddress(), + txHash: `0x${crypto.randomBytes(32).toString("hex")}` + }; + + const { data, status } = await axios.post( + `${TEST_URL}/tips/create`, + payload, + { headers: getTestAuthHeaders() } + ); + + expect(status).toBe(200); + expect(data.result).toBeDefined(); + expect(data.result.fromAddress).toBe(payload.fromAddress); + expect(data.result.toAddress).toBe(payload.toAddress); + expect(data.result.amount).toBe(payload.amount); + expect(data.result.txHash).toBe(payload.txHash); + + await prisma.tip.delete({ where: { id: data.result.id } }); + }); + + test("should return 400 for invalid Ethereum address", async () => { + const invalidTip = { + amount: 50, + fromAddress: "invalid_address", + id: "0x1234-0x5678", + toAddress: "invalid_address", + tokenAddress: "invalid_address", + txHash: "invalid_txHash" + }; + + try { + await axios.post(`${TEST_URL}/tips/create`, invalidTip, { + headers: getTestAuthHeaders() + }); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 400 if request body is missing", async () => { + try { + await axios.post( + `${TEST_URL}/tips/create`, + {}, + { headers: getTestAuthHeaders() } + ); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 401 if the id token is missing", async () => { + try { + await axios.post(`${TEST_URL}/tips/create`, { + amount: 50, + fromAddress: faker.finance.ethereumAddress(), + id: "0x1234-0x5678", + toAddress: faker.finance.ethereumAddress(), + tokenAddress: faker.finance.ethereumAddress(), + txHash: faker.finance.ethereumAddress() + }); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); +}); diff --git a/apps/api/tests/tips/get.spec.ts b/apps/api/tests/tips/get.spec.ts new file mode 100644 index 000000000000..a10000225a2e --- /dev/null +++ b/apps/api/tests/tips/get.spec.ts @@ -0,0 +1,62 @@ +import prisma from "@hey/db/prisma/db/client"; +import axios from "axios"; +import { TEST_URL } from "tests/helpers/constants"; +import getTestAuthHeaders from "tests/helpers/getTestAuthHeaders"; +import { beforeAll, describe, expect, test } from "vitest"; + +describe("POST /tips/get", () => { + const fakePostIds = ["0x0d-0x01", "0x0d-0x02", "0x0d-0x03"]; + + beforeAll(async () => { + await prisma.tip.createMany({ + data: fakePostIds.map((id, index) => ({ + amount: 100 + index * 10, + fromProfileId: "0xTestAccount", + publicationId: id, + toProfileId: "0xTestAccountTo", + fromAddress: "0xFromAddress", + toAddress: "0xToAddress", + tokenAddress: "0xTokenAddress", + txHash: "0xTransactionHash" + })) + }); + }); + + test("should return 200 and fetch tip data for posts", async () => { + const { data, status } = await axios.post( + `${TEST_URL}/tips/get`, + { ids: fakePostIds }, + { headers: getTestAuthHeaders() } + ); + + expect(status).toBe(200); + expect(data.result).toBeInstanceOf(Array); + expect(data.result).toHaveLength(fakePostIds.length); + + data.result.forEach((tipResult: any, index: number) => { + expect(tipResult.id).toBe(fakePostIds[index]); + expect(tipResult.count).toBeDefined(); + expect(tipResult.tipped).toBe(false); + }); + }); + + test("should return 400 if no ids are provided", async () => { + try { + await axios.post( + `${TEST_URL}/tips/get`, + { ids: [] }, + { headers: getTestAuthHeaders() } + ); + } catch (error: any) { + expect(error.response.status).toBe(400); + } + }); + + test("should return 401 if the id token is missing", async () => { + try { + await axios.post(`${TEST_URL}/tips/get`, { ids: fakePostIds }); + } catch (error: any) { + expect(error.response.status).toBe(401); + } + }); +}); diff --git a/apps/api/tests/tokens/all.spec.ts b/apps/api/tests/tokens/all.spec.ts new file mode 100644 index 000000000000..df592105d054 --- /dev/null +++ b/apps/api/tests/tokens/all.spec.ts @@ -0,0 +1,24 @@ +import axios from "axios"; +import { describe, expect, test } from "vitest"; +import { TEST_URL } from "../helpers/constants"; + +describe("GET /tokens/all", () => { + test("should return 200 and valid tokens and structure", async () => { + const { data, status } = await axios.get(`${TEST_URL}/tokens/all`); + + expect(status).toBe(200); + expect(data.result).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: expect.any(String), + name: expect.any(String), + symbol: expect.any(String), + decimals: expect.any(Number), + contractAddress: expect.any(String), + priority: expect.any(Number), + createdAt: expect.any(String) + }) + ]) + ); + }); +}); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json index d88ccc01a35d..d7f16d7e7509 100644 --- a/apps/api/tsconfig.json +++ b/apps/api/tsconfig.json @@ -1,11 +1,10 @@ { - "extends": "tsconfig/nextjs.json", + "extends": "@hey/config/base.tsconfig.json", "compilerOptions": { - "baseUrl": ".", - "noEmit": true, - "paths": { - "@lib*": ["src/lib*"] - } + "module": "CommonJS", + "noEmit": false, + "outDir": "dist", + "baseUrl": "." }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"] + "include": ["**/*.ts", "src", "tests"] } diff --git a/apps/api/vitest.config.ts b/apps/api/vitest.config.ts new file mode 100644 index 000000000000..ab43c1b30bcb --- /dev/null +++ b/apps/api/vitest.config.ts @@ -0,0 +1,13 @@ +import dotenv from "dotenv"; +import { defineConfig } from "vitest/config"; + +dotenv.config({ override: true }); + +export default defineConfig({ + test: { + globals: true, + testTimeout: 30000, + hookTimeout: 30000, + retry: 5 + } +}); diff --git a/apps/cron/.env.example b/apps/cron/.env.example new file mode 100644 index 000000000000..e6b34f34d353 --- /dev/null +++ b/apps/cron/.env.example @@ -0,0 +1,9 @@ +DATABASE_URL="" +LENS_DATABASE_PASSWORD="" +CLICKHOUSE_URL="http://clickhouse.hey.xyz:8123" +CLICKHOUSE_PASSWORD="" +REDIS_URL="" +AWS_ACCESS_KEY_ID="" +AWS_SECRET_ACCESS_KEY="" +EVER_ACCESS_KEY="" +EVER_ACCESS_SECRET="" diff --git a/apps/cron/.prettierignore b/apps/cron/.prettierignore new file mode 100644 index 000000000000..1521c8b7652b --- /dev/null +++ b/apps/cron/.prettierignore @@ -0,0 +1 @@ +dist diff --git a/apps/cron/Dockerfile b/apps/cron/Dockerfile new file mode 100644 index 000000000000..19f957e778a2 --- /dev/null +++ b/apps/cron/Dockerfile @@ -0,0 +1,38 @@ +# Base image +FROM node:18-alpine AS base +RUN apk add --no-cache libc6-compat + +# Installer stage +FROM base AS installer +WORKDIR /app + +# Install pnpm globally +RUN npm install -g pnpm + +# Copy all files to the build context +COPY . . + +# Install dependencies +RUN pnpm install --frozen-lockfile + +# Prune dev dependencies to reduce image size +RUN pnpm prune --prod + +# Runner stage +FROM base AS runner +WORKDIR /app + +# Install pnpm globally +RUN npm install -g pnpm + +# Add non-root user for better security +RUN addgroup --system --gid 1001 hey +RUN adduser --system --uid 1001 app + +USER app + +# Copy built application and production dependencies from builder stage +COPY --from=installer /app . + +# Command to run the app +CMD sleep 3 && pnpm --filter @hey/cron run start diff --git a/apps/cron/env.d.ts b/apps/cron/env.d.ts new file mode 100644 index 000000000000..b710299cd76a --- /dev/null +++ b/apps/cron/env.d.ts @@ -0,0 +1,12 @@ +declare namespace NodeJS { + interface ProcessEnv { + AWS_ACCESS_KEY_ID: string; + AWS_SECRET_ACCESS_KEY: string; + CLICKHOUSE_PASSWORD: string; + DATABASE_URL: string; + EVER_ACCESS_KEY: string; + EVER_ACCESS_SECRET: string; + LENS_DATABASE_PASSWORD: string; + REDIS_URL: string; + } +} diff --git a/apps/cron/package.json b/apps/cron/package.json new file mode 100644 index 000000000000..26205709f146 --- /dev/null +++ b/apps/cron/package.json @@ -0,0 +1,27 @@ +{ + "name": "@hey/cron", + "version": "0.0.0", + "private": true, + "license": "AGPL-3.0", + "scripts": { + "dev": "NODE_ENV=production tsx src/index.ts", + "start": "NODE_ENV=production tsx src/index.ts", + "sync": "NODE_ENV=production tsx src/run.ts", + "typecheck": "tsc --pretty" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.700.0", + "@hey/data": "workspace:*", + "@hey/db": "workspace:*", + "@hey/helpers": "workspace:*", + "dotenv": "^16.4.5", + "node-cron": "^3.0.3", + "tsx": "^4.19.2" + }, + "devDependencies": { + "@hey/config": "workspace:*", + "@types/node": "^22.10.0", + "@types/node-cron": "^3.0.11", + "typescript": "^5.7.2" + } +} diff --git a/apps/cron/src/backupEventsToS3.ts b/apps/cron/src/backupEventsToS3.ts new file mode 100644 index 000000000000..41a66e565982 --- /dev/null +++ b/apps/cron/src/backupEventsToS3.ts @@ -0,0 +1,72 @@ +import clickhouseClient from "@hey/db/clickhouseClient"; +import { generateForeverExpiry, getRedis, setRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; + +const backupEventsToS3 = async () => { + try { + const cacheKey = "backups:events:offset"; + const batchSize = 100000; + + // Get the last offset from Redis (or start from 0 if no offset is stored) + let offset = Number.parseInt((await getRedis(cacheKey)) || "0", 10); + + // Calculate the range for the current batch + const startRange = offset; + const endRange = offset + batchSize; + const s3Path = `events-${startRange}-${endRange}.csv`; + + // Check the number of rows in the current batch + const eventsCount = await clickhouseClient.query({ + format: "JSONEachRow", + query: ` + SELECT count(*) as count + FROM ( + SELECT * + FROM events + ORDER BY created + LIMIT ${batchSize} OFFSET ${offset} + ) as subquery; + ` + }); + + const rowsCount = await eventsCount.json<{ count: string }>(); + + if (Number.parseInt(rowsCount[0].count) === batchSize) { + // Proceed with the backup if there are rows to back up + await clickhouseClient.query({ + format: "JSONEachRow", + query: ` + INSERT INTO FUNCTION + s3( + 'https://leafwatch.s3.eu-west-1.amazonaws.com/${s3Path}', + '${process.env.AWS_ACCESS_KEY_ID}', + '${process.env.AWS_SECRET_ACCESS_KEY}', + 'CSV' + ) + SETTINGS s3_truncate_on_insert=1 + SELECT * FROM events + ORDER BY created + LIMIT ${batchSize} OFFSET ${offset}; + ` + }); + + // Increment the offset + offset += batchSize; + await setRedis(cacheKey, offset.toString(), generateForeverExpiry()); + + logger.info( + `[Cron] backupEventsToS3 - Backup completed successfully for ${s3Path} with offset ${offset}` + ); + } else { + const remainingEvents = batchSize - Number.parseInt(rowsCount[0].count); + + logger.info( + `[Cron] backupEventsToS3 - No more events to back up at offset ${offset}. ${remainingEvents} events still need to be backed up.` + ); + } + } catch (error) { + logger.error("[Cron] backupEventsToS3 - Error processing events", error); + } +}; + +export default backupEventsToS3; diff --git a/apps/cron/src/backupImpressionsToS3.ts b/apps/cron/src/backupImpressionsToS3.ts new file mode 100644 index 000000000000..0f3656ffe2b2 --- /dev/null +++ b/apps/cron/src/backupImpressionsToS3.ts @@ -0,0 +1,76 @@ +import clickhouseClient from "@hey/db/clickhouseClient"; +import { generateForeverExpiry, getRedis, setRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; + +const backupImpressionsToS3 = async () => { + try { + const cacheKey = "backups:impressions:offset"; + const batchSize = 100000; + + // Get the last offset from Redis (or start from 0 if no offset is stored) + let offset = Number.parseInt((await getRedis(cacheKey)) || "0", 10); + + // Calculate the range for the current batch + const startRange = offset; + const endRange = offset + batchSize; + const s3Path = `impressions-${startRange}-${endRange}.csv`; + + // Check the number of rows in the current batch + const impressionsCount = await clickhouseClient.query({ + format: "JSONEachRow", + query: ` + SELECT count(*) as count + FROM ( + SELECT * + FROM impressions + ORDER BY viewed + LIMIT ${batchSize} OFFSET ${offset} + ) as subquery; + ` + }); + + const rowsCount = await impressionsCount.json<{ count: string }>(); + + if (Number.parseInt(rowsCount[0].count) === batchSize) { + // Proceed with the backup if there are rows to back up + await clickhouseClient.query({ + format: "JSONEachRow", + query: ` + INSERT INTO FUNCTION + s3( + 'https://leafwatch.s3.eu-west-1.amazonaws.com/${s3Path}', + '${process.env.AWS_ACCESS_KEY_ID}', + '${process.env.AWS_SECRET_ACCESS_KEY}', + 'CSV' + ) + SETTINGS s3_truncate_on_insert=1 + SELECT * FROM impressions + ORDER BY viewed + LIMIT ${batchSize} OFFSET ${offset}; + ` + }); + + // Increment the offset + offset += batchSize; + await setRedis(cacheKey, offset.toString(), generateForeverExpiry()); + + logger.info( + `[Cron] backupImpressionsToS3 - Backup completed successfully for ${s3Path} with offset ${offset}` + ); + } else { + const remainingImpressions = + batchSize - Number.parseInt(rowsCount[0].count); + + logger.info( + `[Cron] backupImpressionsToS3 - No more impressions to back up at offset ${offset}. ${remainingImpressions} impressions still need to be backed up.` + ); + } + } catch (error) { + logger.error( + "[Cron] backupImpressionsToS3 - Error processing impressions", + error + ); + } +}; + +export default backupImpressionsToS3; diff --git a/apps/cron/src/batchProcessEvents.ts b/apps/cron/src/batchProcessEvents.ts new file mode 100644 index 000000000000..9e63b990bcc9 --- /dev/null +++ b/apps/cron/src/batchProcessEvents.ts @@ -0,0 +1,36 @@ +import clickhouseClient from "@hey/db/clickhouseClient"; +import { lRangeRedis, lTrimRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; + +const batchProcessEvents = async () => { + try { + const startTime = Date.now(); + const events = (await lRangeRedis("events", 0, 999999)) || []; + + if (events.length === 0) { + logger.info("[Cron] batchProcessEvents - No events to process"); + return; + } + + const parsedEvents = events.flatMap((event) => JSON.parse(event)); + + await clickhouseClient.insert({ + format: "JSONEachRow", + table: "events", + values: parsedEvents + }); + + const endTime = Date.now(); + const timeTaken = endTime - startTime; + + logger.info( + `[Cron] batchProcessEvents - Batch inserted ${events.length} events to Clickhouse in ${timeTaken}ms` + ); + + await lTrimRedis("events", events.length, -1); + } catch (error) { + logger.error("[Cron] batchProcessEvents - Error processing events", error); + } +}; + +export default batchProcessEvents; diff --git a/apps/cron/src/batchProcessImpressions.ts b/apps/cron/src/batchProcessImpressions.ts new file mode 100644 index 000000000000..6a57fb31918a --- /dev/null +++ b/apps/cron/src/batchProcessImpressions.ts @@ -0,0 +1,41 @@ +import clickhouseClient from "@hey/db/clickhouseClient"; +import { lRangeRedis, lTrimRedis } from "@hey/db/redisClient"; +import logger from "@hey/helpers/logger"; + +const batchProcessImpressions = async () => { + try { + const startTime = Date.now(); + const impressions = (await lRangeRedis("impressions", 0, 999999)) || []; + + if (impressions.length === 0) { + logger.info("[Cron] batchProcessImpressions - No impressions to process"); + return; + } + + const parsedImpressions = impressions.flatMap((impression) => + JSON.parse(impression) + ); + + await clickhouseClient.insert({ + format: "JSONEachRow", + table: "impressions", + values: parsedImpressions + }); + + const endTime = Date.now(); + const timeTaken = endTime - startTime; + + logger.info( + `[Cron] batchProcessImpressions - Batch inserted ${parsedImpressions.length} impressions to Clickhouse in ${timeTaken}ms` + ); + + await lTrimRedis("impressions", impressions.length, -1); + } catch (error) { + logger.error( + "[Cron] batchProcessImpressions - Error processing impressions", + error + ); + } +}; + +export default batchProcessImpressions; diff --git a/apps/cron/src/cleanClickhouse.ts b/apps/cron/src/cleanClickhouse.ts new file mode 100644 index 000000000000..bee07448b095 --- /dev/null +++ b/apps/cron/src/cleanClickhouse.ts @@ -0,0 +1,45 @@ +import clickhouseClient from "@hey/db/clickhouseClient"; +import logger from "@hey/helpers/logger"; + +// Use this query to get the total size of all tables in Clickhouse +// SELECT +// table, +// formatReadableSize(sum(bytes_on_disk)) AS total_size +// FROM system.parts +// GROUP BY database, table +// ORDER BY total_size DESC; + +const cleanClickhouse = async () => { + const queries = [ + "ALTER TABLE events DELETE WHERE url NOT LIKE '%hey.xyz%';", + "TRUNCATE TABLE system.processors_profile_log;", + "TRUNCATE TABLE system.asynchronous_metric_log;", + "TRUNCATE TABLE system.asynchronous_metric_log_0;", + "TRUNCATE TABLE system.query_log;", + "TRUNCATE TABLE system.query_log_0;", + "TRUNCATE TABLE system.metric_log;", + "TRUNCATE TABLE system.metric_log_0;", + "TRUNCATE TABLE system.metric_log_1;", + "TRUNCATE TABLE system.trace_log;", + "TRUNCATE TABLE system.trace_log_0;", + "TRUNCATE TABLE system.opentelemetry_span_log;", + "TRUNCATE TABLE system.part_log;", + "TRUNCATE TABLE system.part_log_0;", + "TRUNCATE TABLE system.blob_storage_log;", + "TRUNCATE TABLE system.query_views_log;" + ]; + + try { + await Promise.all( + queries.map((query) => clickhouseClient.command({ query })) + ); + + logger.info( + "[Cron] cleanClickhouse - Cleaned non hey.xyz events and system logs from Clickhouse" + ); + } catch (error) { + logger.error("[Cron] cleanClickhouse - Error cleaning Clickhouse", error); + } +}; + +export default cleanClickhouse; diff --git a/apps/cron/src/cleanEmailTokens.ts b/apps/cron/src/cleanEmailTokens.ts new file mode 100644 index 000000000000..122bfc8ea72c --- /dev/null +++ b/apps/cron/src/cleanEmailTokens.ts @@ -0,0 +1,22 @@ +import prisma from "@hey/db/prisma/db/client"; +import logger from "@hey/helpers/logger"; + +const cleanEmailTokens = async () => { + try { + await prisma.email.updateMany({ + data: { tokenExpiresAt: null, verificationToken: null, verified: false }, + where: { tokenExpiresAt: { lt: new Date() } } + }); + + logger.info( + "[Cron] cleanEmailTokens - Cleaned up email tokens that are expired" + ); + } catch (error) { + logger.error( + "[Cron] cleanEmailTokens - Error cleaning email tokens", + error + ); + } +}; + +export default cleanEmailTokens; diff --git a/apps/cron/src/cleanPreferences.ts b/apps/cron/src/cleanPreferences.ts new file mode 100644 index 000000000000..71c19643de4e --- /dev/null +++ b/apps/cron/src/cleanPreferences.ts @@ -0,0 +1,20 @@ +import prisma from "@hey/db/prisma/db/client"; +import logger from "@hey/helpers/logger"; + +const cleanPreferences = async () => { + try { + await prisma.preference.deleteMany({ + where: { + appIcon: 0, + highSignalNotificationFilter: false, + developerMode: false + } + }); + + logger.info("[Cron] cleanPreferences - Cleaned up Preference"); + } catch (error) { + logger.error("[Cron] cleanPreferences - Error cleaning preferences", error); + } +}; + +export default cleanPreferences; diff --git a/apps/cron/src/clearMutedWords.ts b/apps/cron/src/clearMutedWords.ts new file mode 100644 index 000000000000..bd0b4a2b8bf9 --- /dev/null +++ b/apps/cron/src/clearMutedWords.ts @@ -0,0 +1,16 @@ +import prisma from "@hey/db/prisma/db/client"; +import logger from "@hey/helpers/logger"; + +const clearMutedWords = async () => { + try { + await prisma.mutedWord.deleteMany({ + where: { expiresAt: { lte: new Date() } } + }); + + logger.info("[Cron] clearMutedWords - Cleaned up muted words"); + } catch (error) { + logger.error("[Cron] clearMutedWords - Error clearing muted words", error); + } +}; + +export default clearMutedWords; diff --git a/apps/cron/src/dbVacuum.ts b/apps/cron/src/dbVacuum.ts new file mode 100644 index 000000000000..c86274a2010d --- /dev/null +++ b/apps/cron/src/dbVacuum.ts @@ -0,0 +1,13 @@ +import prisma from "@hey/db/prisma/db/client"; +import logger from "@hey/helpers/logger"; + +const dbVacuum = async () => { + try { + await prisma.$queryRaw`VACUUM FULL`; + logger.info("[Cron] dbVacuum - Vacuumed database"); + } catch (error) { + logger.error("[Cron] dbVacuum - Error vacuuming database", error); + } +}; + +export default dbVacuum; diff --git a/apps/cron/src/heartbeat.ts b/apps/cron/src/heartbeat.ts new file mode 100644 index 000000000000..d8cfc9ee06dd --- /dev/null +++ b/apps/cron/src/heartbeat.ts @@ -0,0 +1,16 @@ +import logger from "@hey/helpers/logger"; + +const heartbeat = async () => { + try { + await fetch( + "https://status.hey.xyz/api/push/NM16jFPpBf?status=up&msg=OK&ping=", + { method: "HEAD" } + ); + + logger.info("[Cron] heartbeat - Heartbeat sent to Status API"); + } catch (error) { + logger.error("[Cron] heartbeat - Error sending heartbeat", error); + } +}; + +export default heartbeat; diff --git a/apps/cron/src/index.ts b/apps/cron/src/index.ts new file mode 100644 index 000000000000..854a989679f5 --- /dev/null +++ b/apps/cron/src/index.ts @@ -0,0 +1,75 @@ +import logger from "@hey/helpers/logger"; +import cron from "node-cron"; +import backupEventsToS3 from "./backupEventsToS3"; +import backupImpressionsToS3 from "./backupImpressionsToS3"; +import batchProcessEvents from "./batchProcessEvents"; +import batchProcessImpressions from "./batchProcessImpressions"; +import cleanClickhouse from "./cleanClickhouse"; +import cleanEmailTokens from "./cleanEmailTokens"; +import cleanPreferences from "./cleanPreferences"; +import clearMutedWords from "./clearMutedWords"; +import dbVacuum from "./dbVacuum"; +import heartbeat from "./heartbeat"; +import truncate4EverlandBucket from "./truncate4EverlandBucket"; + +const startCronJobs = () => { + logger.info("Cron jobs are started..."); + + cron.schedule("*/30 * * * * *", async () => { + await heartbeat(); + return; + }); + + cron.schedule("*/5 * * * *", async () => { + await cleanClickhouse(); + return; + }); + + cron.schedule("0 0 * * *", async () => { + await truncate4EverlandBucket(); + return; + }); + + cron.schedule("*/5 * * * *", async () => { + await cleanEmailTokens(); + return; + }); + + cron.schedule("*/5 * * * *", async () => { + await cleanPreferences(); + return; + }); + + cron.schedule("*/5 * * * *", async () => { + await clearMutedWords(); + return; + }); + + cron.schedule("0 */6 * * *", async () => { + await dbVacuum(); + return; + }); + + cron.schedule("*/10 * * * *", async () => { + await batchProcessEvents(); + return; + }); + + cron.schedule("*/1 * * * *", async () => { + await batchProcessImpressions(); + return; + }); + + cron.schedule("*/5 * * * *", async () => { + await backupEventsToS3(); + return; + }); + + cron.schedule("*/5 * * * *", async () => { + await backupImpressionsToS3(); + return; + }); +}; + +// Initialize cron jobs +startCronJobs(); diff --git a/apps/cron/src/run.ts b/apps/cron/src/run.ts new file mode 100644 index 000000000000..4b6aa7df1db2 --- /dev/null +++ b/apps/cron/src/run.ts @@ -0,0 +1,21 @@ +import logger from "@hey/helpers/logger"; +import dotenv from "dotenv"; + +dotenv.config({ override: true }); + +const startJobs = async () => { + logger.info("Jobs are started..."); + + while (true) { + try { + console.log("Jobs are running..."); + } catch (error) { + logger.error("Error during jobs:", error); + } + + await new Promise((resolve) => setTimeout(resolve, 2000)); + } +}; + +// Initialize jobs +startJobs(); diff --git a/apps/cron/src/truncate4EverlandBucket.ts b/apps/cron/src/truncate4EverlandBucket.ts new file mode 100644 index 000000000000..9238d1002a8b --- /dev/null +++ b/apps/cron/src/truncate4EverlandBucket.ts @@ -0,0 +1,91 @@ +import type { ListObjectsV2CommandOutput } from "@aws-sdk/client-s3"; +import { + DeleteObjectsCommand, + ListObjectsV2Command, + S3 +} from "@aws-sdk/client-s3"; +import { EVER_API, EVER_BUCKET, EVER_REGION } from "@hey/data/constants"; +import logger from "@hey/helpers/logger"; + +const truncate4EverlandBucket = async () => { + try { + const accessKeyId = process.env.EVER_ACCESS_KEY; + const secretAccessKey = process.env.EVER_ACCESS_SECRET; + + const s3Client = new S3({ + credentials: { accessKeyId, secretAccessKey }, + endpoint: EVER_API, + maxAttempts: 5, + region: EVER_REGION + }); + + const daysToSubtract = 5; + const currentDate = new Date(); + const dateDaysAgo = new Date( + currentDate.setDate(currentDate.getDate() - daysToSubtract) + ); + + const Bucket = EVER_BUCKET; + let ContinuationToken: string | undefined; + let objectsToDelete: { Key: string }[] = []; + + do { + const response: ListObjectsV2CommandOutput = await s3Client.send( + new ListObjectsV2Command({ Bucket, ContinuationToken }) + ); + const { Contents, IsTruncated, NextContinuationToken } = response; + + if (Contents) { + const oldObjects = Contents.filter( + (object) => + object.LastModified && new Date(object.LastModified) < dateDaysAgo + ); + objectsToDelete = objectsToDelete.concat( + oldObjects + .map((object) => ({ Key: object.Key as string })) + .filter((obj) => obj.Key) + ); + } + + ContinuationToken = IsTruncated ? NextContinuationToken : undefined; + } while (ContinuationToken); + + logger.info( + `[Cron] truncate4EverlandBucket - Found ${objectsToDelete.length} objects older than ${daysToSubtract} days.` + ); + + if (objectsToDelete.length > 0) { + const deleteBatchSize = 1000; + + while (objectsToDelete.length > 0) { + const batch = objectsToDelete.splice(0, deleteBatchSize); + + const deleteCommand = new DeleteObjectsCommand({ + Bucket, + Delete: { Objects: batch } + }); + + s3Client.send(deleteCommand); + logger.info( + `[Cron] truncate4EverlandBucket - Deleted ${batch.length} objects in a batch.` + ); + } + + logger.info( + `[Cron] truncate4EverlandBucket - Deleted all objects older than ${daysToSubtract} days.` + ); + return; + } + + logger.info( + `[Cron] truncate4EverlandBucket - No objects older than ${daysToSubtract} days found.` + ); + } catch (error) { + logger.error( + "[Cron] truncate4EverlandBucket - Error deleting objects", + error + ); + } +}; + +export default truncate4EverlandBucket; diff --git a/apps/cron/tsconfig.json b/apps/cron/tsconfig.json new file mode 100644 index 000000000000..054f99efd6fc --- /dev/null +++ b/apps/cron/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@hey/config/base.tsconfig.json", + "compilerOptions": { + "module": "CommonJS", + "noEmit": false, + "outDir": "dist", + "baseUrl": "." + }, + "include": ["**/*.ts", "src"] +} diff --git a/apps/og/.prettierignore b/apps/og/.prettierignore new file mode 100644 index 000000000000..2b3533c7ee01 --- /dev/null +++ b/apps/og/.prettierignore @@ -0,0 +1,2 @@ +.next +out diff --git a/apps/og/Dockerfile b/apps/og/Dockerfile new file mode 100644 index 000000000000..65262abecb75 --- /dev/null +++ b/apps/og/Dockerfile @@ -0,0 +1,44 @@ +# Base image +FROM node:18-alpine AS base +RUN apk add --no-cache libc6-compat + +# Installer stage +FROM base AS installer +WORKDIR /app + +# Install pnpm globally +RUN npm install -g pnpm + +# Copy all files to the build context +COPY . . + +# Install dependencies +RUN pnpm install --frozen-lockfile + +# Build the project +RUN pnpm --filter @hey/og build + +# Prune dev dependencies to reduce image size +RUN pnpm prune --prod + +# Runner stage +FROM base AS runner +WORKDIR /app + +# Install pnpm globally +RUN npm install -g pnpm + +# Add non-root user for better security +RUN addgroup --system --gid 1001 hey +RUN adduser --system --uid 1001 app + +USER app + +# Copy built application and production dependencies from builder stage +COPY --from=installer /app . + +# Expose the port +EXPOSE 4785 + +# Command to run the app +CMD pnpm --filter @hey/og run start diff --git a/apps/og/next-env.d.ts b/apps/og/next-env.d.ts new file mode 100644 index 000000000000..40c3d68096c2 --- /dev/null +++ b/apps/og/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/apps/og/next.config.js b/apps/og/next.config.js new file mode 100644 index 000000000000..44b9709c2cf1 --- /dev/null +++ b/apps/og/next.config.js @@ -0,0 +1,7 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + poweredByHeader: false, + reactStrictMode: true +}; + +module.exports = nextConfig; diff --git a/apps/og/package.json b/apps/og/package.json new file mode 100644 index 000000000000..d0be0400b959 --- /dev/null +++ b/apps/og/package.json @@ -0,0 +1,29 @@ +{ + "name": "@hey/og", + "version": "0.0.0", + "private": true, + "license": "AGPL-3.0", + "scripts": { + "build": "next build", + "dev": "next dev --port 4785", + "start": "next start --port 4785", + "test": "vitest run", + "typecheck": "tsc --pretty" + }, + "dependencies": { + "@hey/config": "workspace:*", + "@hey/data": "workspace:*", + "@hey/helpers": "workspace:*", + "@hey/indexer": "workspace:*", + "apollo-utilities": "^1.3.4", + "graphql": "^16.9.0", + "next": "^15.0.2", + "react": "^18.3.1" + }, + "devDependencies": { + "@types/node": "^22.10.0", + "@types/react": "^18.3.12", + "typescript": "^5.7.2", + "vitest": "^2.1.5" + } +} diff --git a/apps/og/public/next.svg b/apps/og/public/next.svg new file mode 100644 index 000000000000..5174b28c565c --- /dev/null +++ b/apps/og/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/og/public/vercel.svg b/apps/og/public/vercel.svg new file mode 100644 index 000000000000..d2f84222734f --- /dev/null +++ b/apps/og/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/apps/og/src/app/layout.tsx b/apps/og/src/app/layout.tsx new file mode 100644 index 000000000000..fc8b8275ae4e --- /dev/null +++ b/apps/og/src/app/layout.tsx @@ -0,0 +1,15 @@ +import type { Metadata } from "next"; +import type { ReactNode } from "react"; +import defaultMetadata from "src/defaultMetadata"; + +export const metadata: Metadata = defaultMetadata; + +const RootLayout = ({ children }: { children: ReactNode }) => { + return ( + + {children} + + ); +}; + +export default RootLayout; diff --git a/apps/og/src/app/page.tsx b/apps/og/src/app/page.tsx new file mode 100644 index 000000000000..268350e65e5d --- /dev/null +++ b/apps/og/src/app/page.tsx @@ -0,0 +1,11 @@ +import { APP_NAME } from "@hey/data/constants"; + +const Home = () => { + return ( +
+
{APP_NAME}
+
+ ); +}; + +export default Home; diff --git a/apps/og/src/app/posts/[id]/page.tsx b/apps/og/src/app/posts/[id]/page.tsx new file mode 100644 index 000000000000..8d9e5cc21820 --- /dev/null +++ b/apps/og/src/app/posts/[id]/page.tsx @@ -0,0 +1,145 @@ +import getCollectModuleMetadata from "@helpers/getCollectModuleMetadata"; +import getPostOGImages from "@helpers/getPostOGImages"; +import { APP_NAME } from "@hey/data/constants"; +import getAccount from "@hey/helpers/getAccount"; +import getPostData from "@hey/helpers/getPostData"; +import logger from "@hey/helpers/logger"; +import { isRepost } from "@hey/helpers/postHelpers"; +import type { AnyPublication } from "@hey/lens"; +import { PublicationDocument } from "@hey/lens"; +import { addTypenameToDocument } from "apollo-utilities"; +import { print } from "graphql"; +import type { Metadata } from "next"; +import defaultMetadata from "src/defaultMetadata"; + +interface Props { + params: Promise<{ id: string }>; +} + +export const generateMetadata = async ({ + params +}: Props): Promise => { + const { id } = await params; + + const response = await fetch("https://api-v2.lens.dev", { + body: JSON.stringify({ + operationName: "Publication", + query: print(addTypenameToDocument(PublicationDocument)), + variables: { request: { forId: id } } + }), + cache: "no-store", + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + + const result = await response.json(); + + if (!result.data.publication) { + return defaultMetadata; + } + + const post = result.data.publication as AnyPublication; + const targetPost = isRepost(post) ? post.mirrorOn : post; + const { by: account, metadata } = targetPost; + const filteredContent = getPostData(metadata)?.content || ""; + const filteredAsset = getPostData(metadata)?.asset; + const assetIsAudio = filteredAsset?.type === "Audio"; + + const { displayName, link, slugWithPrefix } = getAccount(account); + const title = `${targetPost.__typename} by ${slugWithPrefix} • ${APP_NAME}`; + const description = (filteredContent || title).slice(0, 155); + + return { + alternates: { canonical: `https://hey.xyz/posts/${targetPost.id}` }, + applicationName: APP_NAME, + authors: { + name: displayName, + url: `https://hey.xyz${link}` + }, + creator: displayName, + description: description, + keywords: [ + "hey", + "hey.xyz", + "social media post", + "social media", + "lenster", + "polygon", + "profile post", + "like", + "share", + "post", + "lens", + "lens protocol", + "decentralized", + "web3", + displayName, + slugWithPrefix + ], + metadataBase: new URL(`https://hey.xyz/posts/${targetPost.id}`), + openGraph: { + description: description, + images: getPostOGImages(metadata) as any, + siteName: "Hey", + type: "article", + url: `https://hey.xyz/posts/${targetPost.id}` + }, + other: { + "count:actions": targetPost.stats.countOpenActions, + "count:comments": targetPost.stats.comments, + "count:likes": targetPost.stats.reactions, + "count:mirrors": targetPost.stats.mirrors, + "count:quotes": targetPost.stats.quotes, + "lens:id": targetPost.id, + ...getCollectModuleMetadata(targetPost) + }, + publisher: displayName, + title: title, + twitter: { + card: assetIsAudio ? "summary" : "summary_large_image", + site: "@heydotxyz" + } + }; +}; + +const Page = async ({ params }: Props) => { + const { id } = await params; + const metadata = await generateMetadata({ params }); + + if (!metadata) { + return

{id}

; + } + + const postUrl = `https://hey.xyz/posts/${metadata.other?.["lens:id"]}`; + + logger.info(`[OG] Fetched post /posts/${metadata.other?.["lens:id"]}`); + + return ( + <> +

{metadata.title?.toString()}

+

{metadata.description?.toString()}

+ + + ); +}; + +export default Page; diff --git a/apps/og/src/app/u/[handle]/page.tsx b/apps/og/src/app/u/[handle]/page.tsx new file mode 100644 index 000000000000..1af7ebd7e0df --- /dev/null +++ b/apps/og/src/app/u/[handle]/page.tsx @@ -0,0 +1,118 @@ +import { APP_NAME } from "@hey/data/constants"; +import getAccount from "@hey/helpers/getAccount"; +import getAvatar from "@hey/helpers/getAvatar"; +import logger from "@hey/helpers/logger"; +import { type Account, AccountDocument } from "@hey/indexer"; +import { print } from "graphql"; +import type { Metadata } from "next"; +import defaultMetadata from "src/defaultMetadata"; + +interface Props { + params: Promise<{ username: string }>; +} + +export const generateMetadata = async ({ + params +}: Props): Promise => { + const { username } = await params; + + const response = await fetch("https://api-v2.lens.dev", { + body: JSON.stringify({ + operationName: "Account", + query: print(AccountDocument), + variables: { + request: { username: { localName: username } } + } + }), + cache: "no-store", + headers: { "Content-Type": "application/json" }, + method: "POST" + }); + + const result = await response.json(); + + if (!result.data.profile) { + return defaultMetadata; + } + + const account = result.data.profile as Account; + const { displayName, link, slugWithPrefix } = getAccount(account); + const title = `${displayName} (${slugWithPrefix}) • ${APP_NAME}`; + const description = (account?.metadata?.bio || title).slice(0, 155); + + return { + alternates: { canonical: `https://hey.xyz${link}` }, + applicationName: APP_NAME, + creator: displayName, + description: description, + keywords: [ + "hey", + "hey.xyz", + "social media profile", + "social media", + "lenster", + "polygon", + "profile", + "lens", + "lens protocol", + "decentralized", + "web3", + displayName, + slugWithPrefix + ], + metadataBase: new URL(`https://hey.xyz${link}`), + openGraph: { + description: description, + images: [getAvatar(account)], + siteName: "Hey", + type: "profile", + url: `https://hey.xyz${link}` + }, + other: { + "count:followers": account.stats.followers, + "count:following": account.stats.following, + "lens:username": username, + "lens:id": account.address + }, + publisher: displayName, + title: title, + twitter: { card: "summary", site: "@heydotxyz" } + }; +}; + +const Page = async ({ params }: Props) => { + const { username } = await params; + const metadata = await generateMetadata({ params }); + + if (!metadata) { + return

{username}

; + } + + const profileUrl = `https://hey.xyz/u/${metadata.other?.["lens:username"]}`; + + logger.info(`[OG] Fetched profile /u/${username}`); + + return ( + <> +

{metadata.title?.toString()}

+

{metadata.description?.toString()}

+ + + ); +}; + +export default Page; diff --git a/apps/og/src/defaultMetadata.ts b/apps/og/src/defaultMetadata.ts new file mode 100644 index 000000000000..0df65652efcf --- /dev/null +++ b/apps/og/src/defaultMetadata.ts @@ -0,0 +1,29 @@ +import { APP_NAME, DEFAULT_OG, DESCRIPTION } from "@hey/data/constants"; +import type { Metadata } from "next"; + +const defaultMetadata: Metadata = { + alternates: { canonical: "https://hey.xyz" }, + applicationName: APP_NAME, + description: DESCRIPTION, + keywords: [ + "hey", + "hey.xyz", + "social media", + "lenster", + "polygon", + "lens", + "lens protocol", + "decentralized", + "web3" + ], + metadataBase: new URL("https://hey.xyz"), + openGraph: { + images: [DEFAULT_OG], + siteName: "Hey", + type: "website" + }, + title: APP_NAME, + twitter: { card: "summary_large_image", site: "@heydotxyz" } +}; + +export default defaultMetadata; diff --git a/apps/og/src/helpers/getCollectModuleMetadata.spec.ts b/apps/og/src/helpers/getCollectModuleMetadata.spec.ts new file mode 100644 index 000000000000..bbb6933044d8 --- /dev/null +++ b/apps/og/src/helpers/getCollectModuleMetadata.spec.ts @@ -0,0 +1,135 @@ +import getPostOGImages from "@helpers/getPostOGImages"; +import { APP_NAME } from "@hey/data/constants"; +import allowedOpenActionModules from "@hey/helpers/allowedOpenActionModules"; +import getAccount from "@hey/helpers/getAccount"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import getCollectModuleMetadata from "./getCollectModuleMetadata"; + +// Mock the imported functions +vi.mock("@hey/helpers/getAccount", () => ({ + default: vi.fn() +})); + +vi.mock("@helpers/getPostOGImages", () => ({ + default: vi.fn() +})); + +describe("getCollectModuleMetadata", () => { + const mockProfile = { slugWithPrefix: "@john_doe" }; + const mockOGImages = ["https://example.com/media.jpg"]; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("should return correct metadata when open action module is present", () => { + (getAccount as any).mockReturnValue(mockProfile); + (getPostOGImages as any).mockReturnValue(mockOGImages); + + const post = { + __typename: "MirrorablePublication", + by: { + ownedBy: { address: "0x1234567890abcdef" } + }, + metadata: { + title: "Test Post" + }, + openActionModules: [ + { + type: allowedOpenActionModules[0], + contract: { address: "0xabcdefabcdef" } + } + ], + stats: { countOpenActions: 10 }, + id: "post-id-123" + } as any; + + const result = getCollectModuleMetadata(post); + + expect(result).toEqual({ + "eth:nft:chain": "polygon", + "eth:nft:collection": `MirrorablePublication by @john_doe • ${APP_NAME}`, + "eth:nft:contract_address": "0xabcdefabcdef", + "eth:nft:creator_address": "0x1234567890abcdef", + "eth:nft:media_url": "https://example.com/media.jpg", + "eth:nft:mint_count": 10, + "eth:nft:mint_url": "https://hey.xyz/posts/post-id-123", + "eth:nft:schema": "ERC721" + }); + }); + + test("should return undefined when no open action module is present", () => { + const post = { + __typename: "MirrorablePublication", + by: { + ownedBy: { address: "0x1234567890abcdef" } + }, + metadata: { + title: "Test Post" + }, + openActionModules: [], + stats: { countOpenActions: 0 }, + id: "post-id-123" + } as any; + + const result = getCollectModuleMetadata(post); + expect(result).toBeUndefined(); + }); + + test("should return undefined when collect module is not found", () => { + const post = { + __typename: "MirrorablePublication", + by: { + ownedBy: { address: "0x1234567890abcdef" } + }, + metadata: { + title: "Test Post" + }, + openActionModules: [ + { type: "NonAllowedModuleType", contract: { address: "0xabcdef" } } + ], + stats: { countOpenActions: 0 }, + id: "post-id-123" + } as any; + + const result = getCollectModuleMetadata(post); + expect(result).toBeUndefined(); + }); + + test("should return undefined when openActionModules is missing", () => { + const post = { + __typename: "MirrorablePublication", + by: { + ownedBy: { address: "0x1234567890abcdef" } + }, + metadata: { + title: "Test Post" + }, + stats: { countOpenActions: 0 }, + id: "post-id-123" + } as any; + + const result = getCollectModuleMetadata(post); + expect(result).toBeUndefined(); + }); + + test("should return undefined when no valid collect module is found", () => { + const post = { + __typename: "MirrorablePublication", + by: { + ownedBy: { address: "0x1234567890abcdef" } + }, + metadata: { + title: "Test Post" + }, + openActionModules: [ + { type: "UnknownModuleType", contract: { address: "0xabcdef" } } + ], + stats: { countOpenActions: 0 }, + id: "post-id-123" + } as any; + + const result = getCollectModuleMetadata(post); + expect(result).toBeUndefined(); + }); +}); diff --git a/apps/og/src/helpers/getCollectModuleMetadata.ts b/apps/og/src/helpers/getCollectModuleMetadata.ts new file mode 100644 index 000000000000..6b65592b0b6a --- /dev/null +++ b/apps/og/src/helpers/getCollectModuleMetadata.ts @@ -0,0 +1,39 @@ +import getPostOGImages from "@helpers/getPostOGImages"; +import { APP_NAME } from "@hey/data/constants"; +import allowedOpenActionModules from "@hey/helpers/allowedOpenActionModules"; +import getAccount from "@hey/helpers/getAccount"; +import type { MirrorablePublication } from "@hey/lens"; + +const getCollectModuleMetadata = (post: MirrorablePublication) => { + const { openActionModules } = post; + + if (!openActionModules) { + return; + } + + const openAction = openActionModules.filter((module) => + allowedOpenActionModules.includes(module.type) + ); + + // 0 th index is the collect module + const collectModule = openAction.length ? openAction[0] : null; + + if (!collectModule) { + return; + } + + const { slugWithPrefix } = getAccount(post.by); + + return { + "eth:nft:chain": "polygon", + "eth:nft:collection": `${post.__typename} by ${slugWithPrefix} • ${APP_NAME}`, + "eth:nft:contract_address": collectModule.contract.address, + "eth:nft:creator_address": post.by.owner, + "eth:nft:media_url": getPostOGImages(post.metadata)[0], + "eth:nft:mint_count": post.stats.countOpenActions, + "eth:nft:mint_url": `https://hey.xyz/posts/${post.id}`, + "eth:nft:schema": "ERC721" + }; +}; + +export default getCollectModuleMetadata; diff --git a/apps/og/src/helpers/getPostOGImages.spec.ts b/apps/og/src/helpers/getPostOGImages.spec.ts new file mode 100644 index 000000000000..6b8b744fe4f2 --- /dev/null +++ b/apps/og/src/helpers/getPostOGImages.spec.ts @@ -0,0 +1,99 @@ +import getPostData from "@hey/helpers/getPostData"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import getPostOGImages from "./getPostOGImages"; + +vi.mock("@hey/helpers/getPostData", () => ({ + default: vi.fn() +})); + +describe("getPostOGImages", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + test("should return image URIs from attachments when asset is an image", () => { + const mockMetadata = { __typename: "ImageMetadata" } as any; + const mockPostData = { + attachments: [ + { uri: "https://example.com/image1.jpg" }, + { uri: "https://example.com/image2.jpg" } + ], + asset: { type: "Image", uri: "https://example.com/asset-image.jpg" } + }; + + (getPostData as any).mockReturnValue(mockPostData); + + const result = getPostOGImages(mockMetadata); + expect(result).toEqual([ + "https://example.com/image1.jpg", + "https://example.com/image2.jpg" + ]); + }); + + test("should return asset image URI when no attachments are present", () => { + const mockMetadata = { __typename: "ImageMetadata" } as any; + const mockPostData = { + attachments: [], + asset: { type: "Image", uri: "https://example.com/asset-image.jpg" } + }; + + (getPostData as any).mockReturnValue(mockPostData); + + const result = getPostOGImages(mockMetadata); + expect(result).toEqual(["https://example.com/asset-image.jpg"]); + }); + + test("should return video cover URI when no attachments are present", () => { + const mockMetadata = { __typename: "VideoMetadata" } as any; + const mockPostData = { + attachments: [], + asset: { type: "Video", cover: "https://example.com/video-cover.jpg" } + }; + + (getPostData as any).mockReturnValue(mockPostData); + + const result = getPostOGImages(mockMetadata); + expect(result).toEqual(["https://example.com/video-cover.jpg"]); + }); + + test("should return audio cover URI when no attachments are present", () => { + const mockMetadata = { __typename: "AudioMetadata" } as any; + const mockPostData = { + attachments: [], + asset: { type: "Audio", cover: "https://example.com/audio-cover.jpg" } + }; + + (getPostData as any).mockReturnValue(mockPostData); + + const result = getPostOGImages(mockMetadata); + expect(result).toEqual(["https://example.com/audio-cover.jpg"]); + }); + + test("should return attachment URIs for video if present", () => { + const mockMetadata = { __typename: "VideoMetadata" } as any; + const mockPostData = { + attachments: [ + { uri: "https://example.com/video1.jpg" }, + { uri: "https://example.com/video2.jpg" } + ], + asset: { type: "Video", cover: "https://example.com/video-cover.jpg" } + }; + + (getPostData as any).mockReturnValue(mockPostData); + + const result = getPostOGImages(mockMetadata); + expect(result).toEqual([ + "https://example.com/video1.jpg", + "https://example.com/video2.jpg" + ]); + }); + + test("should return empty array if no valid data is present", () => { + const mockMetadata = { __typename: "TextOnlyMetadata" } as any; + + (getPostData as any).mockReturnValue({ attachments: [], asset: {} }); + + const result = getPostOGImages(mockMetadata); + expect(result).toEqual([]); + }); +}); diff --git a/apps/og/src/helpers/getPostOGImages.ts b/apps/og/src/helpers/getPostOGImages.ts new file mode 100644 index 000000000000..24c2be2804ba --- /dev/null +++ b/apps/og/src/helpers/getPostOGImages.ts @@ -0,0 +1,39 @@ +import getPostData from "@hey/helpers/getPostData"; +import type { PublicationMetadata } from "@hey/lens"; + +const getPostOGImages = (metadata: PublicationMetadata) => { + const filteredAttachments = getPostData(metadata)?.attachments || []; + const filteredAsset = getPostData(metadata)?.asset; + + const assetIsImage = filteredAsset?.type === "Image"; + const assetIsVideo = filteredAsset?.type === "Video"; + const assetIsAudio = filteredAsset?.type === "Audio"; + + if (assetIsImage) { + if (filteredAttachments.length > 0) { + return filteredAttachments.map((attachment) => attachment.uri); + } + + return [filteredAsset?.uri]; + } + + if (assetIsVideo) { + if (filteredAttachments.length > 0) { + return filteredAttachments.map((attachment) => attachment.uri); + } + + return [filteredAsset?.cover]; + } + + if (assetIsAudio) { + if (filteredAttachments.length > 0) { + return filteredAttachments.map((attachment) => attachment.uri); + } + + return [filteredAsset?.cover]; + } + + return []; +}; + +export default getPostOGImages; diff --git a/apps/og/tsconfig.json b/apps/og/tsconfig.json new file mode 100644 index 000000000000..3e03980a22b8 --- /dev/null +++ b/apps/og/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "@hey/config/react.tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "lib": ["DOM", "DOM.Iterable", "ESNext", "webworker"], + "paths": { + "@components*": ["src/components*"], + "@helpers*": ["src/helpers*"] + }, + "plugins": [ + { + "name": "next" + } + ] + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"] +} diff --git a/apps/og/vitest.config.ts b/apps/og/vitest.config.ts new file mode 100644 index 000000000000..e419bf6933b3 --- /dev/null +++ b/apps/og/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + testTimeout: 30000, + retry: 5 + } +}); diff --git a/apps/web/.env.example b/apps/web/.env.example index 82a07166a0ce..a138c11e3890 100644 --- a/apps/web/.env.example +++ b/apps/web/.env.example @@ -1,4 +1,4 @@ -# mainnet, testnet, staging, sandbox or staging-sandbox -NEXT_PUBLIC_LENS_NETWORK="testnet" -NEXT_PUBLIC_RELAY_ON=false -NEXT_PUBLIC_DATADOG_API_KEY="" +NEXT_PUBLIC_IS_PRODUCTION=false +NEXT_PUBLIC_LENS_NETWORK="testnet" # mainnet, testnet +NEXT_PUBLIC_AXIOM_DATASET="hey" +NEXT_PUBLIC_AXIOM_TOKEN="" diff --git a/apps/web/.eslintrc.js b/apps/web/.eslintrc.js deleted file mode 100644 index ac8818912f23..000000000000 --- a/apps/web/.eslintrc.js +++ /dev/null @@ -1,4 +0,0 @@ -module.exports = { - root: true, - extends: ['weblint'] -}; diff --git a/apps/web/.prettierignore b/apps/web/.prettierignore new file mode 100644 index 000000000000..57b52d0c3282 --- /dev/null +++ b/apps/web/.prettierignore @@ -0,0 +1,3 @@ +public/sw.js +.next +out diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index 4f11a03dc6cc..a4a7b3f5cfa2 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -2,4 +2,4 @@ /// // NOTE: This file should not be edited -// see https://nextjs.org/docs/basic-features/typescript for more information. +// see https://nextjs.org/docs/pages/building-your-application/configuring/typescript for more information. diff --git a/apps/web/next.config.js b/apps/web/next.config.js deleted file mode 100644 index 8546d5dcb2e6..000000000000 --- a/apps/web/next.config.js +++ /dev/null @@ -1,55 +0,0 @@ -/** @type {import('next').NextConfig} */ -const withTM = require('next-transpile-modules')(['lens', 'data', 'abis']); -const headers = [{ key: 'Cache-Control', value: 'public, max-age=3600' }]; - -module.exports = withTM({ - reactStrictMode: false, - trailingSlash: false, - experimental: { - scrollRestoration: true, - newNextLinkBehavior: true - }, - async rewrites() { - return [ - { - source: '/sitemap.xml', - destination: 'https://sitemap.lenster.xyz/sitemap.xml' - }, - { - source: '/sitemaps/:match*', - destination: 'https://sitemap.lenster.xyz/sitemaps/:match*' - } - ]; - }, - async redirects() { - return [ - { - source: '/discord', - destination: 'https://discord.com/invite/B8eKhSSUwX', - permanent: true - }, - { - source: '/donate', - destination: 'https://gitcoin.co/grants/5007/lenster', - permanent: true - } - ]; - }, - async headers() { - return [ - { - source: '/(.*)', - headers: [ - { key: 'X-Content-Type-Options', value: 'nosniff' }, - { key: 'X-Frame-Options', value: 'DENY' }, - { key: 'X-XSS-Protection', value: '1; mode=block' }, - { key: 'Referrer-Policy', value: 'strict-origin' }, - { key: 'Permissions-Policy', value: 'interest-cohort=()' } - ] - }, - { source: '/about', headers }, - { source: '/privacy', headers }, - { source: '/thanks', headers } - ]; - } -}); diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs new file mode 100644 index 000000000000..4b3d47426848 --- /dev/null +++ b/apps/web/next.config.mjs @@ -0,0 +1,87 @@ +import bundleAnalyzer from "@next/bundle-analyzer"; +import { withAxiom } from "next-axiom"; + +const withBundleAnalyzer = bundleAnalyzer({ + enabled: process.env.ANALYZE === "true" +}); + +const allowedBots = + ".*(bot|telegram|baidu|bing|yandex|iframely|whatsapp|facebook).*"; +const { VERCEL_DEPLOYMENT_ID } = process.env; +const DEPLOYMENT_ID = VERCEL_DEPLOYMENT_ID || "unknown"; + +// Remove data-testid from production +const isDevelopment = process.env.NODE_ENV === "development"; +const compilerOptions = isDevelopment + ? {} + : { compiler: { reactRemoveProperties: { properties: ["^data-testid$"] } } }; + +/** @type {import('next').NextConfig} */ +const nextConfig = { + ...compilerOptions, + headers() { + return [ + { + headers: [ + { key: "Referrer-Policy", value: "strict-origin" }, + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "X-XSS-Protection", value: "1; mode=block" }, + { key: "X-Hey-Deployment", value: DEPLOYMENT_ID } + ], + source: "/(.*)" + } + ]; + }, + poweredByHeader: false, + productionBrowserSourceMaps: true, + reactStrictMode: false, + redirects() { + return [ + { + destination: "/?signup=true", + permanent: false, + source: "/signup" + }, + { + destination: "https://discord.com/invite/B8eKhSSUwX", + permanent: true, + source: "/discord" + }, + { + destination: + "https://zora.co/collect/zora:0xf2086c0eaa8b34b0eef73920d0b1b53f4146e2e4/1?referrer=0x03Ba34f6Ea1496fa316873CF8350A3f7eaD317EF", + permanent: true, + source: "/zorb" + }, + { + destination: + "https://explorer.gitcoin.co/#/round/42161/608/6?utm_source=hey.xyz", + permanent: true, + source: "/gitcoin" + }, + // Redirect: hey.xyz/u/lens/ > hey.xyz/u/ + { + destination: "/u/:handle", + permanent: true, + source: "/u/:namespace/:handle" + } + ]; + }, + rewrites() { + return [ + { + destination: "https://og.hey.xyz/u/:match*", + has: [{ key: "user-agent", type: "header", value: allowedBots }], + source: "/u/:match*" + }, + { + destination: "https://og.hey.xyz/posts/:match*", + has: [{ key: "user-agent", type: "header", value: allowedBots }], + source: "/posts/:match*" + } + ]; + }, + transpilePackages: ["data"] +}; + +export default withBundleAnalyzer(withAxiom(nextConfig)); diff --git a/apps/web/package.json b/apps/web/package.json index e03972a2c4d2..2249473bd20e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,69 +1,92 @@ { - "name": "web", + "name": "@hey/web", "version": "0.0.0", "private": true, + "license": "AGPL-3.0", "scripts": { + "build": "pnpm build:sw; next build", + "build:sw": "node script/build-sw.mjs", "dev": "next dev --port 4783", - "build": "next build", - "start": "next start", - "typecheck": "tsc --noEmit", - "lint": "eslint . --ext .js,.ts,.tsx" + "start": "next start --port 4783", + "test": "vitest run", + "typecheck": "tsc --pretty" }, "dependencies": { - "@apollo/client": "^3.7.1", - "@giphy/js-fetch-api": "^4.3.1", - "@giphy/react-components": "^6.2.0", - "@headlessui/react": "^1.7.4", - "@heroicons/react": "v1", - "@hookform/resolvers": "^2.9.10", - "@lexical/code": "^0.6.0", - "@lexical/hashtag": "^0.6.0", - "@lexical/link": "^0.6.0", - "@lexical/markdown": "^0.6.0", - "@lexical/react": "0.6.0", - "@tippyjs/react": "^4.2.6", - "@xmtp/xmtp-js": "^7.1.1", - "axios": "^1.1.3", - "clsx": "^1.2.1", - "dayjs": "^1.11.5", - "dotenv": "^16.0.3", - "ethers": "5.6.0", - "framer-motion": "^7.6.7", - "graphql": "^16.6.0", - "interweave": "^13.0.0", - "jwt-decode": "^3.1.2", - "lexical": "0.6.0", - "next": "^13.0.2", - "next-themes": "^0.2.1", - "next-transpile-modules": "^10.0.0", - "plyr-react": "^5.1.0", - "react": "^18.2.0", - "react-copy-to-clipboard": "^5.1.0", - "react-dom": "^18.2.0", - "react-hook-form": "^7.39.3", - "react-hot-toast": "^2.4.0", - "react-infinite-scroll-component": "^6.1.0", - "uuid": "^9.0.0", - "wagmi": "^0.8.4", - "zod": "^3.19.1", - "zustand": "^4.1.4" + "@apollo/client": "^3.11.10", + "@aws-sdk/client-s3": "^3.700.0", + "@aws-sdk/lib-storage": "^3.700.0", + "@headlessui/react": "2.2.0", + "@heroicons/react": "^2.2.0", + "@hey/abis": "workspace:*", + "@hey/data": "workspace:*", + "@hey/db": "workspace:*", + "@hey/helpers": "workspace:*", + "@hey/icons": "workspace:*", + "@hey/image-cropper": "workspace:*", + "@hey/indexer": "workspace:*", + "@hey/ui": "workspace:*", + "@lens-protocol/metadata": "^1.2.0", + "@livepeer/react": "^4.2.8", + "@next/bundle-analyzer": "^15.0.2", + "@radix-ui/react-hover-card": "^1.1.2", + "@rajesh896/broprint.js": "^2.1.1", + "@tanstack/react-query": "^5.61.3", + "@uidotdev/usehooks": "^2.4.1", + "@unleash/proxy-client-react": "^4.4.0", + "axios": "^1.7.8", + "browser-image-compression": "^2.0.2", + "chart.js": "^4.4.6", + "dotenv": "^16.4.5", + "esbuild": "^0.24.0", + "franc": "^6.2.0", + "graphql": "^16.9.0", + "next": "^15.0.2", + "next-axiom": "^1.5.1", + "next-themes": "^0.4.3", + "party-js": "^2.2.0", + "plur": "^5.1.0", + "plyr-react": "^5.3.0", + "prosekit": "^0.10.4", + "rc-slider": "^11.1.7", + "react": "^18.3.1", + "react-chartjs-2": "^5.2.0", + "react-device-detect": "^2.2.3", + "react-dom": "^18.3.1", + "react-hot-toast": "^2.4.1", + "react-markdown": "^9.0.0", + "react-tracked": "^2.0.1", + "react-virtuoso": "^4.12.2", + "rehype-parse": "^9.0.1", + "rehype-remark": "^10.0.0", + "remark-breaks": "^4.0.0", + "remark-html": "^16.0.1", + "remark-linkify-regex": "^1.2.1", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "strip-markdown": "^6.0.0", + "unified": "^11.0.5", + "unist-util-visit-parents": "^6.0.1", + "urlcat": "^3.1.0", + "use-resize-observer": "^9.1.0", + "uuid": "^11.0.2", + "viem": "^2.21.51", + "wagmi": "^2.13.0", + "zod": "^3.23.8", + "zustand": "5.0.1" }, "devDependencies": { + "@hey/config": "workspace:*", + "@hey/types": "workspace:*", "@tailwindcss/aspect-ratio": "^0.4.2", - "@tailwindcss/forms": "^0.5.3", - "@tailwindcss/line-clamp": "^0.4.2", - "@types/react": "^18.0.23", - "@types/react-copy-to-clipboard": "^5.0.4", - "@types/react-dom": "^18.0.9", - "@types/uuid": "^8.3.4", - "abis": "*", - "autoprefixer": "^10.4.13", - "data": "*", - "eslint-config-weblint": "*", - "lens": "*", - "postcss": "^8.4.19", - "tailwindcss": "^3.2.1", - "tsconfig": "*", - "typescript": "^4.9.3" + "@tailwindcss/forms": "^0.5.9", + "@types/node": "^22.10.0", + "@types/react": "^18.3.12", + "@types/react-dom": "^18.3.1", + "@types/uuid": "^10.0.0", + "autoprefixer": "^10.4.20", + "postcss": "^8.4.49", + "tailwindcss": "^3.4.14", + "typescript": "^5.7.2", + "vitest": "^2.1.5" } } diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js index 5cbc2c7d8770..cd1a2b940ac7 100644 --- a/apps/web/postcss.config.js +++ b/apps/web/postcss.config.js @@ -1,6 +1,6 @@ module.exports = { plugins: { - tailwindcss: {}, - autoprefixer: {} + autoprefixer: {}, + tailwindcss: {} } }; diff --git a/apps/web/public/.well-known/security.txt b/apps/web/public/.well-known/security.txt deleted file mode 100644 index 42e693e5d951..000000000000 --- a/apps/web/public/.well-known/security.txt +++ /dev/null @@ -1,6 +0,0 @@ -Contact: https://github.com/lensterxyz/lenster/security/advisories -Expires: 2022-12-31T18:29:00.000Z -Acknowledgments: https://github.com/lensterxyz/lenster/security/advisories?state=published -Preferred-Languages: en -Canonical: https://lenster.xyz/.well-known/security.txt -Policy: https://github.com/lensterxyz/lenster/security/policy diff --git a/apps/web/public/16x16.png b/apps/web/public/16x16.png new file mode 100644 index 000000000000..d6fdcdc1231e Binary files /dev/null and b/apps/web/public/16x16.png differ diff --git a/apps/web/public/32x32.png b/apps/web/public/32x32.png new file mode 100644 index 000000000000..a5b50115de4d Binary files /dev/null and b/apps/web/public/32x32.png differ diff --git a/apps/web/public/assets/type-bold.svg b/apps/web/public/assets/type-bold.svg deleted file mode 100755 index b84d940c51bf..000000000000 --- a/apps/web/public/assets/type-bold.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/apps/web/public/assets/type-code.svg b/apps/web/public/assets/type-code.svg deleted file mode 100755 index 094704db38bf..000000000000 --- a/apps/web/public/assets/type-code.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/apps/web/public/assets/type-italic.svg b/apps/web/public/assets/type-italic.svg deleted file mode 100755 index a72c14c8e8d0..000000000000 --- a/apps/web/public/assets/type-italic.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - \ No newline at end of file diff --git a/apps/web/public/ati.png b/apps/web/public/ati.png new file mode 100644 index 000000000000..d4e64b4a275b Binary files /dev/null and b/apps/web/public/ati.png differ diff --git a/apps/web/public/favicon.ico b/apps/web/public/favicon.ico index 6c7e174f4152..f701aa5d46ae 100644 Binary files a/apps/web/public/favicon.ico and b/apps/web/public/favicon.ico differ diff --git a/apps/web/public/fonts/SofiaProSoftBold.woff2 b/apps/web/public/fonts/SofiaProSoftBold.woff2 new file mode 100644 index 000000000000..256d76d20b62 Binary files /dev/null and b/apps/web/public/fonts/SofiaProSoftBold.woff2 differ diff --git a/apps/web/public/fonts/SofiaProSoftMed.woff2 b/apps/web/public/fonts/SofiaProSoftMed.woff2 new file mode 100644 index 000000000000..45c4a4e21950 Binary files /dev/null and b/apps/web/public/fonts/SofiaProSoftMed.woff2 differ diff --git a/apps/web/public/fonts/SofiaProSoftReg.woff2 b/apps/web/public/fonts/SofiaProSoftReg.woff2 new file mode 100644 index 000000000000..89c3979d5dac Binary files /dev/null and b/apps/web/public/fonts/SofiaProSoftReg.woff2 differ diff --git a/apps/web/public/lens.png b/apps/web/public/lens.png deleted file mode 100644 index 593dc4e290b1..000000000000 Binary files a/apps/web/public/lens.png and /dev/null differ diff --git a/apps/web/public/lens.svg b/apps/web/public/lens.svg new file mode 100644 index 000000000000..211694939627 --- /dev/null +++ b/apps/web/public/lens.svg @@ -0,0 +1,3 @@ + + + diff --git a/apps/web/public/logo.png b/apps/web/public/logo.png new file mode 100644 index 000000000000..848bd86457fd Binary files /dev/null and b/apps/web/public/logo.png differ diff --git a/apps/web/public/logo.svg b/apps/web/public/logo.svg deleted file mode 100644 index 316c608ae508..000000000000 --- a/apps/web/public/logo.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/apps/web/public/opensearch.xml b/apps/web/public/opensearch.xml index 1aba84b80e66..6549c628f1dc 100644 --- a/apps/web/public/opensearch.xml +++ b/apps/web/public/opensearch.xml @@ -1,8 +1,8 @@ - Lenster - Lenster Search - - https://lenster.xyz/favicon.ico + Hey + Hey Search + + https://hey.xyz/32x32.png UTF-8 - https://lenster.xyz + https://hey.xyz diff --git a/apps/web/public/pride.png b/apps/web/public/pride.png new file mode 100644 index 000000000000..d943b4253480 Binary files /dev/null and b/apps/web/public/pride.png differ diff --git a/apps/web/public/pride.svg b/apps/web/public/pride.svg deleted file mode 100644 index e9959e7cab5a..000000000000 --- a/apps/web/public/pride.svg +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/apps/web/public/robots.txt b/apps/web/public/robots.txt index 51670a2b4554..d3e881a8f1fd 100644 --- a/apps/web/public/robots.txt +++ b/apps/web/public/robots.txt @@ -1,4 +1,13 @@ User-agent: * -Disallow: /_next/* +Allow: /* +Disallow: /settings/* +Disallow: /staff/* +Disallow: /mod +Disallow: /bookmarks +Disallow: /notifications +Disallow: /search?q= -Sitemap: https://lenster.xyz/sitemap.xml +User-agent: nsa +Disallow: / + +Sitemap: https://api.hey.xyz/sitemap/sitemap.xml diff --git a/apps/web/script/build-sw.mjs b/apps/web/script/build-sw.mjs new file mode 100644 index 000000000000..17ded391445d --- /dev/null +++ b/apps/web/script/build-sw.mjs @@ -0,0 +1,17 @@ +import dotenv from "dotenv"; +import esbuild from "esbuild"; + +dotenv.config(); + +const outfile = "public/sw.js"; + +esbuild.build({ + allowOverwrite: true, + bundle: true, + entryPoints: ["./src/service-workers/index.ts"], + format: "esm", + minify: true, + outfile, + platform: "browser", + target: "es2020" +}); diff --git a/apps/web/src/apollo.ts b/apps/web/src/apollo.ts deleted file mode 100644 index 2b8d476ed7bb..000000000000 --- a/apps/web/src/apollo.ts +++ /dev/null @@ -1,150 +0,0 @@ -import { - ApolloClient, - ApolloLink, - from, - fromPromise, - HttpLink, - InMemoryCache, - toPromise -} from '@apollo/client'; -import { RetryLink } from '@apollo/client/link/retry'; -import { cursorBasedPagination } from '@lib/cursorBasedPagination'; -import { publicationKeyFields } from '@lib/keyFields'; -import parseJwt from '@lib/parseJwt'; -import axios from 'axios'; -import { API_URL, LS_KEYS } from 'data/constants'; -import result from 'lens'; - -const REFRESH_AUTHENTICATION_MUTATION = ` - mutation Refresh($request: RefreshRequest!) { - refresh(request: $request) { - accessToken - refreshToken - } - } -`; - -const httpLink = new HttpLink({ - uri: API_URL, - fetchOptions: 'no-cors', - fetch -}); - -// RetryLink is a link that retries requests based on the status code returned. -const retryLink = new RetryLink({ - delay: { - initial: 100 - }, - attempts: { - max: 2, - retryIf: (error) => !!error - } -}); - -const clearStorage = () => { - localStorage.removeItem('accessToken'); - localStorage.removeItem('refreshToken'); - localStorage.removeItem(LS_KEYS.LENSTER_STORE); - localStorage.removeItem(LS_KEYS.TRANSACTION_STORE); - localStorage.removeItem(LS_KEYS.MESSAGE_STORE); -}; - -const authLink = new ApolloLink((operation, forward) => { - const accessToken = localStorage.getItem('accessToken'); - - if (!accessToken || accessToken === 'undefined') { - clearStorage(); - return forward(operation); - } - - const expiringSoon = Date.now() >= parseJwt(accessToken)?.exp * 1000; - - if (!expiringSoon) { - operation.setContext({ - headers: { - 'x-access-token': accessToken ? `Bearer ${accessToken}` : '' - } - }); - - return forward(operation); - } - - return fromPromise( - axios(API_URL, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - data: JSON.stringify({ - operationName: 'Refresh', - query: REFRESH_AUTHENTICATION_MUTATION, - variables: { - request: { refreshToken: localStorage.getItem('refreshToken') } - } - }) - }) - .then(({ data }) => { - const accessToken = data?.data?.refresh?.accessToken; - const refreshToken = data?.data?.refresh?.refreshToken; - operation.setContext({ - headers: { - 'x-access-token': `Bearer ${accessToken}` - } - }); - - localStorage.setItem('accessToken', accessToken); - localStorage.setItem('refreshToken', refreshToken); - - return toPromise(forward(operation)); - }) - .catch(() => { - return toPromise(forward(operation)); - }) - ); -}); - -const cache = new InMemoryCache({ - possibleTypes: result.possibleTypes, - typePolicies: { - Post: { keyFields: publicationKeyFields }, - Comment: { keyFields: publicationKeyFields }, - Mirror: { keyFields: publicationKeyFields }, - Query: { - fields: { - timeline: cursorBasedPagination(['request', ['profileId']]), - feed: cursorBasedPagination(['request', ['profileId', 'feedEventItemTypes']]), - feedHighlights: cursorBasedPagination(['request', ['profileId']]), - explorePublications: cursorBasedPagination(['request', ['sortCriteria', 'metadata']]), - publications: cursorBasedPagination([ - 'request', - ['profileId', 'commentsOf', 'publicationTypes', 'metadata'] - ]), - nfts: cursorBasedPagination(['request', ['ownerAddress', 'chainIds']]), - notifications: cursorBasedPagination(['request', ['profileId', 'notificationTypes']]), - followers: cursorBasedPagination(['request', ['profileId']]), - following: cursorBasedPagination(['request', ['address']]), - search: cursorBasedPagination(['request', ['query', 'type']]), - profiles: cursorBasedPagination([ - 'request', - ['profileIds', 'ownedBy', 'handles', 'whoMirroredPublicationId'] - ]), - whoCollectedPublication: cursorBasedPagination(['request', ['publicationId']]), - whoReactedPublication: cursorBasedPagination(['request', ['publicationId']]), - mutualFollowersProfiles: cursorBasedPagination([ - 'request', - ['viewingProfileId', 'yourProfileId', 'limit'] - ]) - } - } - } -}); - -const client = new ApolloClient({ - link: from([retryLink, authLink, httpLink]), - cache -}); - -export const serverlessClient = new ApolloClient({ - link: from([httpLink]), - cache -}); - -export default client; diff --git a/apps/web/src/components/Account/AccountFeed.tsx b/apps/web/src/components/Account/AccountFeed.tsx new file mode 100644 index 000000000000..f3d7acd78031 --- /dev/null +++ b/apps/web/src/components/Account/AccountFeed.tsx @@ -0,0 +1,207 @@ +import SinglePost from "@components/Post/SinglePost"; +import PostsShimmer from "@components/Shared/Shimmer/PostsShimmer"; +import { ChatBubbleBottomCenterIcon } from "@heroicons/react/24/outline"; +import { AccountFeedType } from "@hey/data/enums"; +import type { AnyPublication, PublicationsRequest } from "@hey/lens"; +import { + LimitType, + PublicationMetadataMainFocusType, + PublicationType, + usePublicationsQuery +} from "@hey/lens"; +import { Card, EmptyState, ErrorMessage } from "@hey/ui"; +import type { FC } from "react"; +import { useEffect, useRef } from "react"; +import type { StateSnapshot, VirtuosoHandle } from "react-virtuoso"; +import { Virtuoso } from "react-virtuoso"; +import { useAccountFeedStore } from "src/store/non-persisted/useAccountFeedStore"; +import { useImpressionsStore } from "src/store/non-persisted/useImpressionsStore"; +import { useTipsStore } from "src/store/non-persisted/useTipsStore"; +import { useAccountStore } from "src/store/persisted/useAccountStore"; +import { useTransactionStore } from "src/store/persisted/useTransactionStore"; + +let virtuosoState: any = { ranges: [], screenTop: 0 }; + +interface AccountFeedProps { + handle: string; + accountDetailsLoading: boolean; + address: string; + type: + | AccountFeedType.Collects + | AccountFeedType.Feed + | AccountFeedType.Media + | AccountFeedType.Replies; +} + +const AccountFeed: FC = ({ + handle, + accountDetailsLoading, + address, + type +}) => { + const { currentAccount } = useAccountStore(); + const { mediaFeedFilters } = useAccountFeedStore(); + const { fetchAndStoreViews } = useImpressionsStore(); + const { fetchAndStoreTips } = useTipsStore(); + const { indexedPostHash } = useTransactionStore(); + const virtuoso = useRef(null); + + useEffect(() => { + virtuosoState = { ranges: [], screenTop: 0 }; + }, [address, handle]); + + const getMediaFilters = () => { + const filters: PublicationMetadataMainFocusType[] = []; + if (mediaFeedFilters.images) { + filters.push(PublicationMetadataMainFocusType.Image); + } + if (mediaFeedFilters.video) { + filters.push(PublicationMetadataMainFocusType.Video); + } + if (mediaFeedFilters.audio) { + filters.push(PublicationMetadataMainFocusType.Audio); + } + return filters; + }; + + const publicationTypes: PublicationType[] = + type === AccountFeedType.Feed + ? [PublicationType.Post, PublicationType.Mirror, PublicationType.Quote] + : type === AccountFeedType.Replies + ? [PublicationType.Comment] + : type === AccountFeedType.Media + ? [ + PublicationType.Post, + PublicationType.Comment, + PublicationType.Quote + ] + : [ + PublicationType.Post, + PublicationType.Comment, + PublicationType.Mirror + ]; + const metadata = + type === AccountFeedType.Media + ? { mainContentFocus: getMediaFilters() } + : null; + const request: PublicationsRequest = { + limit: LimitType.TwentyFive, + where: { + metadata, + publicationTypes, + ...(type !== AccountFeedType.Collects + ? { from: [address] } + : { actedBy: address }) + } + }; + + const { data, error, fetchMore, loading, refetch } = usePublicationsQuery({ + onCompleted: async ({ publications }) => { + const ids = + publications?.items?.map((p) => { + return p.__typename === "Mirror" ? p.mirrorOn?.id : p.id; + }) || []; + await fetchAndStoreViews(ids); + await fetchAndStoreTips(ids); + }, + skip: !address, + variables: { request } + }); + + const posts = data?.publications?.items; + const pageInfo = data?.publications?.pageInfo; + const hasMore = pageInfo?.next; + + useEffect(() => { + if (indexedPostHash && currentAccount?.address === address) { + refetch(); + } + }, [indexedPostHash]); + + const onScrolling = (scrolling: boolean) => { + if (!scrolling) { + virtuoso?.current?.getState((state: StateSnapshot) => { + virtuosoState = { ...state }; + }); + } + }; + + const onEndReached = async () => { + if (hasMore) { + const { data } = await fetchMore({ + variables: { request: { ...request, cursor: pageInfo?.next } } + }); + const ids = + data?.publications?.items?.map((p) => { + return p.__typename === "Mirror" ? p.mirrorOn?.id : p.id; + }) || []; + await fetchAndStoreViews(ids); + await fetchAndStoreTips(ids); + } + }; + + if (loading || accountDetailsLoading) { + return ; + } + + if (posts?.length === 0) { + const emptyMessage = + type === AccountFeedType.Feed + ? "has nothing in their feed yet!" + : type === AccountFeedType.Media + ? "has no media yet!" + : type === AccountFeedType.Replies + ? "hasn't replied yet!" + : type === AccountFeedType.Collects + ? "hasn't collected anything yet!" + : ""; + + return ( + } + message={ +
+ {handle} + {emptyMessage} +
+ } + /> + ); + } + + if (error) { + return ; + } + + return ( + + `${post.id}-${index}`} + data={posts} + endReached={onEndReached} + isScrolling={onScrolling} + itemContent={(index, post) => ( + + )} + ref={virtuoso} + restoreStateFrom={ + virtuosoState.ranges.length === 0 + ? virtuosoState?.current?.getState((state: StateSnapshot) => state) + : virtuosoState + } + useWindowScroll + /> + + ); +}; + +export default AccountFeed; diff --git a/apps/web/src/components/Account/AccountStatus.tsx b/apps/web/src/components/Account/AccountStatus.tsx new file mode 100644 index 000000000000..7792c4d66392 --- /dev/null +++ b/apps/web/src/components/Account/AccountStatus.tsx @@ -0,0 +1,34 @@ +import getAccountDetails, { + GET_ACCOUNT_DETAILS_QUERY_KEY +} from "@hey/helpers/api/getAccountDetails"; +import { Tooltip } from "@hey/ui"; +import { useQuery } from "@tanstack/react-query"; +import type { FC } from "react"; + +interface AccountStatusProps { + address: string; +} + +const AccountStatus: FC = ({ address }) => { + const { data } = useQuery({ + enabled: Boolean(address), + queryFn: () => getAccountDetails(address), + queryKey: [GET_ACCOUNT_DETAILS_QUERY_KEY, address] + }); + + if (!data?.status) { + return null; + } + + if (!data.status.message) { + return {data.status.emoji}; + } + + return ( + + {data.status.emoji} + + ); +}; + +export default AccountStatus; diff --git a/apps/web/src/components/Account/Badges/HeyAccount.tsx b/apps/web/src/components/Account/Badges/HeyAccount.tsx new file mode 100644 index 000000000000..dc2172177c59 --- /dev/null +++ b/apps/web/src/components/Account/Badges/HeyAccount.tsx @@ -0,0 +1,19 @@ +import { APP_NAME, STATIC_IMAGES_URL } from "@hey/data/constants"; +import { Tooltip } from "@hey/ui"; +import type { FC } from "react"; + +const HeyAccount: FC = () => { + return ( + + {`Account + + ); +}; + +export default HeyAccount; diff --git a/apps/web/src/components/Account/Badges/HeyNft.tsx b/apps/web/src/components/Account/Badges/HeyNft.tsx new file mode 100644 index 000000000000..346f45abc17b --- /dev/null +++ b/apps/web/src/components/Account/Badges/HeyNft.tsx @@ -0,0 +1,19 @@ +import { APP_NAME, STATIC_IMAGES_URL } from "@hey/data/constants"; +import { Tooltip } from "@hey/ui"; +import type { FC } from "react"; + +const HeyNft: FC = () => { + return ( + + {`Owner + + ); +}; + +export default HeyNft; diff --git a/apps/web/src/components/Account/Badges/index.tsx b/apps/web/src/components/Account/Badges/index.tsx new file mode 100644 index 000000000000..55e0c87b1cf7 --- /dev/null +++ b/apps/web/src/components/Account/Badges/index.tsx @@ -0,0 +1,64 @@ +import { HEY_API_URL, IS_MAINNET } from "@hey/data/constants"; +import { useQuery } from "@tanstack/react-query"; +import axios from "axios"; +import type { FC } from "react"; +import HeyAccount from "./HeyAccount"; +import HeyNft from "./HeyNft"; + +const GET_IS_HEY_ACCOUNT_QUERY_KEY = "getIsHeyAccount"; +const GET_HAS_HEY_NFT_QUERY_KEY = "getHasHeyNft"; + +interface BadgesProps { + address: string; +} + +const Badges: FC = ({ address }) => { + // Begin: Get isHeyAccount + const getIsHeyAccount = async (): Promise => { + const { data } = await axios.get(`${HEY_API_URL}/badges/isHeyAccount`, { + params: { address } + }); + + return data?.isHeyAccount || false; + }; + + const { data: isHeyAccount } = useQuery({ + queryFn: getIsHeyAccount, + queryKey: [GET_IS_HEY_ACCOUNT_QUERY_KEY, address] + }); + // End: Get isHeyAccount + + // Begin: Check has Hey NFT + const getHasHeyNft = async (): Promise => { + const { data } = await axios.get(`${HEY_API_URL}/badges/hasHeyNft`, { + params: { address } + }); + + return data?.hasHeyNft || false; + }; + + const { data: hasHeyNft } = useQuery({ + enabled: IS_MAINNET, + queryFn: getHasHeyNft, + queryKey: [GET_HAS_HEY_NFT_QUERY_KEY, address] + }); + // End: Check has Hey NFT + + const hasBadges = isHeyAccount || hasHeyNft; + + if (!hasBadges) { + return null; + } + + return ( + <> +
+
+ {isHeyAccount && } + {hasHeyNft && } +
+ + ); +}; + +export default Badges; diff --git a/apps/web/src/components/Account/CreatorTool.tsx b/apps/web/src/components/Account/CreatorTool.tsx new file mode 100644 index 000000000000..c5756a38fdff --- /dev/null +++ b/apps/web/src/components/Account/CreatorTool.tsx @@ -0,0 +1,88 @@ +import ToggleWrapper from "@components/Staff/Users/Overview/Tool/ToggleWrapper"; +import errorToast from "@helpers/errorToast"; +import { getAuthApiHeaders } from "@helpers/getAuthApiHeaders"; +import { Leafwatch } from "@helpers/leafwatch"; +import { HEY_API_URL } from "@hey/data/constants"; +import { Permission, PermissionId } from "@hey/data/permissions"; +import { CREATORTOOLS } from "@hey/data/tracking"; +import getInternalAccount, { + GET_INTERNAL_ACCOUNT_QUERY_KEY +} from "@hey/helpers/api/getInternalAccount"; +import type { Account } from "@hey/indexer"; +import { Toggle } from "@hey/ui"; +import { useQuery } from "@tanstack/react-query"; +import axios from "axios"; +import type { FC } from "react"; +import { useEffect, useState } from "react"; +import toast from "react-hot-toast"; + +interface CreatorToolProps { + account: Account; +} + +const CreatorTool: FC = ({ account }) => { + const [updating, setUpdating] = useState(false); + const [permissions, setPermissions] = useState([]); + + const allowedPermissions = [ + { id: PermissionId.Verified, key: Permission.Verified }, + { id: PermissionId.StaffPick, key: Permission.StaffPick } + ]; + + const { data: preferences, isLoading } = useQuery({ + queryFn: () => getInternalAccount(account.address, getAuthApiHeaders()), + queryKey: [GET_INTERNAL_ACCOUNT_QUERY_KEY, account.address] + }); + + useEffect(() => { + if (preferences) { + setPermissions(preferences.permissions || []); + } + }, [preferences]); + + const updatePermissions = async (permission: { id: string; key: string }) => { + const { id, key } = permission; + const enabled = !permissions.includes(key); + + try { + setUpdating(true); + await axios.post( + `${HEY_API_URL}/internal/creator-tools/assign`, + { enabled, id, accountId: account.address }, + { headers: getAuthApiHeaders() } + ); + + toast.success("Permission updated"); + setPermissions((prev) => + enabled ? [...prev, key] : prev.filter((f) => f !== key) + ); + Leafwatch.track(CREATORTOOLS.ASSIGN_PERMISSION, { + permission: key, + accountId: account.address + }); + } catch (error) { + errorToast(error); + } finally { + setUpdating(false); + } + }; + + return ( +
+
Creator Tool
+
+ {allowedPermissions.map((permission) => ( + + updatePermissions(permission)} + /> + + ))} +
+
+ ); +}; + +export default CreatorTool; diff --git a/apps/web/src/components/Account/Details.tsx b/apps/web/src/components/Account/Details.tsx new file mode 100644 index 000000000000..a75af447dbc1 --- /dev/null +++ b/apps/web/src/components/Account/Details.tsx @@ -0,0 +1,251 @@ +import FollowUnfollowButton from "@components/Shared/Account/FollowUnfollowButton"; +import Misuse from "@components/Shared/Account/Icons/Misuse"; +import Verified from "@components/Shared/Account/Icons/Verified"; +import Markup from "@components/Shared/Markup"; +import Slug from "@components/Shared/Slug"; +import { + ClockIcon, + Cog6ToothIcon, + MapPinIcon, + PaintBrushIcon, + ShieldCheckIcon +} from "@heroicons/react/24/outline"; +import { EyeSlashIcon } from "@heroicons/react/24/solid"; +import { EXPANDED_AVATAR, STATIC_IMAGES_URL } from "@hey/data/constants"; +import { FeatureFlag } from "@hey/data/feature-flags"; +import formatDate from "@hey/helpers/datetime/formatDate"; +import getAccount from "@hey/helpers/getAccount"; +import getAccountAttribute from "@hey/helpers/getAccountAttribute"; +import getAvatar from "@hey/helpers/getAvatar"; +import getFavicon from "@hey/helpers/getFavicon"; +import getLennyURL from "@hey/helpers/getLennyURL"; +import getMentions from "@hey/helpers/getMentions"; +import type { Account } from "@hey/indexer"; +import { FollowModuleType } from "@hey/lens"; +import { Button, Drawer, H3, Image, LightBox, Tooltip } from "@hey/ui"; +import { useFlag } from "@unleash/proxy-client-react"; +import { useTheme } from "next-themes"; +import Link from "next/link"; +import { useRouter } from "next/router"; +import type { FC, ReactNode } from "react"; +import { useState } from "react"; +import { useAccountStore } from "src/store/persisted/useAccountStore"; +import urlcat from "urlcat"; +import AccountStatus from "./AccountStatus"; +import Badges from "./Badges"; +import Followerings from "./Followerings"; +import InternalTools from "./InternalTools"; +import AccountMenu from "./Menu"; +import MutualFollowersOverview from "./MutualFollowersOverview"; +import ScamWarning from "./ScamWarning"; +import UpdateTheme from "./UpdateTheme"; + +const MetaDetails = ({ + children, + icon +}: { + children: ReactNode; + icon: ReactNode; +}) => ( +
+ {icon} +
{children}
+
+); + +interface DetailsProps { + isSuspended: boolean; + account: Account; +} + +const Details: FC = ({ isSuspended = false, account }) => { + const { push } = useRouter(); + const { currentAccount } = useAccountStore(); + const [expandedImage, setExpandedImage] = useState(null); + const [showPersonalizeModal, setShowPersonalizeModal] = useState(false); + const isStaff = useFlag(FeatureFlag.Staff); + const { resolvedTheme } = useTheme(); + + const followType = account?.followModule?.type; + + return ( +
+
+ {account.address} setExpandedImage(getAvatar(account, EXPANDED_AVATAR))} + onError={({ currentTarget }) => { + currentTarget.src = getLennyURL(account.address); + }} + src={getAvatar(account)} + width={128} + /> + setExpandedImage(null)} url={expandedImage} /> +
+
+
+

{getAccount(account).displayName}

+ + + {isSuspended ? ( + + + + ) : null} + +
+
+ + {account.operations?.isFollowedByMe ? ( +
+ Follows you +
+ ) : null} +
+
+ {account?.metadata?.bio ? ( +
+ + {account?.metadata.bio} + +
+ ) : null} +
+ + +
+ {currentAccount?.address === account.address ? ( + <> + + + + ) : followType !== FollowModuleType.RevertFollowModule ? ( + + ) : null} + +
+ {currentAccount?.address !== account.address ? ( + + ) : null} +
+
+ {isStaff ? ( + } + > + + Open in Staff Tools + + + ) : null} + {getAccountAttribute("location", account?.metadata?.attributes) ? ( + }> + {getAccountAttribute("location", account?.metadata?.attributes)} + + ) : null} + {getAccountAttribute("website", account?.metadata?.attributes) ? ( + + } + > + + {getAccountAttribute("website", account?.metadata?.attributes) + ?.replace("https://", "") + .replace("http://", "")} + + + ) : null} + {getAccountAttribute("x", account?.metadata?.attributes) ? ( + + } + > + + {getAccountAttribute( + "x", + account?.metadata?.attributes + )?.replace("https://x.com/", "")} + + + ) : null} + }> + Joined {formatDate(account.createdAt)} + +
+
+ + + setShowPersonalizeModal(false)} + show={showPersonalizeModal} + > + + +
+ ); +}; + +export default Details; diff --git a/apps/web/src/components/Account/FeedType.tsx b/apps/web/src/components/Account/FeedType.tsx new file mode 100644 index 000000000000..a46f50e6a26f --- /dev/null +++ b/apps/web/src/components/Account/FeedType.tsx @@ -0,0 +1,80 @@ +import { Leafwatch } from "@helpers/leafwatch"; +import { + ChatBubbleLeftIcon, + FilmIcon, + ListBulletIcon, + PencilSquareIcon, + ShoppingBagIcon +} from "@heroicons/react/24/outline"; +import { AccountFeedType } from "@hey/data/enums"; +import { ACCOUNT } from "@hey/data/tracking"; +import { TabButton } from "@hey/ui"; +import type { Dispatch, FC, SetStateAction } from "react"; +import MediaFilter from "./Filters/MediaFilter"; + +interface FeedTypeProps { + feedType: AccountFeedType; + setFeedType?: Dispatch>; +} + +const FeedType: FC = ({ feedType, setFeedType }) => { + const handleSwitchTab = (type: AccountFeedType) => { + if (setFeedType) { + setFeedType(type); + } + Leafwatch.track(ACCOUNT.SWITCH_ACCOUNT_FEED_TAB, { + accountFeedType: type.toLowerCase() + }); + }; + + const tabs = [ + { + icon: , + name: "Feed", + type: AccountFeedType.Feed + }, + { + icon: , + name: "Replies", + type: AccountFeedType.Replies + }, + { + icon: , + name: "Media", + type: AccountFeedType.Media + }, + { + icon: , + name: "Collected", + type: AccountFeedType.Collects + }, + { + icon: , + name: "Lists", + type: AccountFeedType.Lists + } + ].filter( + (tab): tab is { icon: JSX.Element; name: string; type: AccountFeedType } => + Boolean(tab) + ); + + return ( +
+
+ {tabs.map((tab) => ( + handleSwitchTab(tab.type)} + type={tab.type.toLowerCase()} + /> + ))} +
+ {feedType === AccountFeedType.Media && } +
+ ); +}; + +export default FeedType; diff --git a/apps/web/src/components/Account/Filters/MediaFilter.tsx b/apps/web/src/components/Account/Filters/MediaFilter.tsx new file mode 100644 index 000000000000..9d8618ce5d80 --- /dev/null +++ b/apps/web/src/components/Account/Filters/MediaFilter.tsx @@ -0,0 +1,85 @@ +import MenuTransition from "@components/Shared/MenuTransition"; +import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react"; +import { AdjustmentsVerticalIcon } from "@heroicons/react/24/outline"; +import { Checkbox, Tooltip } from "@hey/ui"; +import cn from "@hey/ui/cn"; +import type { ChangeEvent } from "react"; +import { useAccountFeedStore } from "src/store/non-persisted/useAccountFeedStore"; + +const MediaFilter = () => { + const { mediaFeedFilters, setMediaFeedFilters } = useAccountFeedStore(); + + const handleChange = (e: ChangeEvent) => { + setMediaFeedFilters({ + ...mediaFeedFilters, + [e.target.name]: e.target.checked + }); + }; + + return ( + + + + + + + + + + cn( + { "dropdown-active": focus }, + "menu-item flex cursor-pointer items-center gap-1 space-x-1 rounded-lg" + ) + } + > + + + + cn( + { "dropdown-active": focus }, + "menu-item flex cursor-pointer items-center gap-1 space-x-1 rounded-lg" + ) + } + > + + + + cn( + { "dropdown-active": focus }, + "menu-item flex cursor-pointer items-center gap-1 space-x-1 rounded-lg" + ) + } + > + + + + + + ); +}; + +export default MediaFilter; diff --git a/apps/web/src/components/Account/Followerings.tsx b/apps/web/src/components/Account/Followerings.tsx new file mode 100644 index 000000000000..80c8980dcbf7 --- /dev/null +++ b/apps/web/src/components/Account/Followerings.tsx @@ -0,0 +1,71 @@ +import Followers from "@components/Shared/Modal/Followers"; +import Following from "@components/Shared/Modal/Following"; +import { Leafwatch } from "@helpers/leafwatch"; +import { ACCOUNT } from "@hey/data/tracking"; +import getAccount from "@hey/helpers/getAccount"; +import humanize from "@hey/helpers/humanize"; +import type { Account } from "@hey/indexer"; +import { H4, Modal } from "@hey/ui"; +import plur from "plur"; +import { type FC, useState } from "react"; + +interface FolloweringsProps { + account: Account; +} + +const Followerings: FC = ({ account }) => { + const [showFollowingModal, setShowFollowingModal] = useState(false); + const [showFollowersModal, setShowFollowersModal] = useState(false); + const { followers, following } = account.stats; + + return ( +
+ + + { + Leafwatch.track(ACCOUNT.OPEN_FOLLOWING); + setShowFollowingModal(false); + }} + show={showFollowingModal} + title="Following" + size="md" + > + + + { + Leafwatch.track(ACCOUNT.OPEN_FOLLOWERS); + setShowFollowersModal(false); + }} + show={showFollowersModal} + title="Followers" + size="md" + > + + +
+ ); +}; + +export default Followerings; diff --git a/apps/web/src/components/Account/InternalTools.tsx b/apps/web/src/components/Account/InternalTools.tsx new file mode 100644 index 000000000000..72e8a0702a37 --- /dev/null +++ b/apps/web/src/components/Account/InternalTools.tsx @@ -0,0 +1,33 @@ +import { FeatureFlag } from "@hey/data/feature-flags"; +import type { Account } from "@hey/indexer"; +import { Card } from "@hey/ui"; +import { useFlag } from "@unleash/proxy-client-react"; +import type { FC } from "react"; +import CreatorTool from "./CreatorTool"; +import StaffTool from "./StaffTool"; + +interface InternalToolsProps { + account: Account; +} + +const InternalTools: FC = ({ account }) => { + const hasCreatorToolAccess = useFlag(FeatureFlag.CreatorTools); + const isStaff = useFlag(FeatureFlag.Staff); + + if (!hasCreatorToolAccess || !isStaff) { + return null; + } + + return ( + + {hasCreatorToolAccess && } + {isStaff && } + + ); +}; + +export default InternalTools; diff --git a/apps/web/src/components/Account/Lists/index.tsx b/apps/web/src/components/Account/Lists/index.tsx new file mode 100644 index 000000000000..7d7b3f582a85 --- /dev/null +++ b/apps/web/src/components/Account/Lists/index.tsx @@ -0,0 +1,182 @@ +import Loader from "@components/Shared/Loader"; +import SingleList from "@components/Shared/SingleList"; +import errorToast from "@helpers/errorToast"; +import { getAuthApiHeaders } from "@helpers/getAuthApiHeaders"; +import { + BookmarkIcon as BookmarkIconOutline, + ListBulletIcon +} from "@heroicons/react/24/outline"; +import { BookmarkIcon as BookmarkIconSolid } from "@heroicons/react/24/solid"; +import { HEY_API_URL } from "@hey/data/constants"; +import getLists, { GET_LISTS_QUERY_KEY } from "@hey/helpers/api/lists/getLists"; +import getAccount from "@hey/helpers/getAccount"; +import type { Account } from "@hey/indexer"; +import type { List } from "@hey/types/hey"; +import { + Button, + Card, + EmptyState, + ErrorMessage, + H5, + Modal, + Tooltip +} from "@hey/ui"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import axios from "axios"; +import type { FC } from "react"; +import { useState } from "react"; +import toast from "react-hot-toast"; +import { useAccountStore } from "src/store/persisted/useAccountStore"; +import CreateOrEdit from "../../Shared/List/CreateOrEdit"; + +interface ListsProps { + account: Account; +} + +const Lists: FC = ({ account }) => { + const { currentAccount } = useAccountStore(); + const [showCreateModal, setShowCreateModal] = useState(false); + const [deleting, setDeleting] = useState(false); + const [deletingList, setDeletingList] = useState(null); + const [pinning, setPinning] = useState(false); + const [pinningList, setPinningList] = useState(null); + const queryClient = useQueryClient(); + + const { data, isLoading, error } = useQuery({ + queryFn: () => getLists({ ownerId: account.address }), + queryKey: [GET_LISTS_QUERY_KEY, account.address] + }); + + const handleDeleteList = async (id: string) => { + try { + setDeleting(true); + setDeletingList(id); + + const confirmed = await confirm( + "Are you sure you want to delete this list?" + ); + + if (!confirmed) { + return toast.error("Operation cancelled"); + } + + await axios.post( + `${HEY_API_URL}/lists/delete`, + { id }, + { headers: getAuthApiHeaders() } + ); + + queryClient.setQueryData( + [GET_LISTS_QUERY_KEY, currentAccount?.address], + (oldData) => oldData?.filter((list) => list.id !== id) + ); + toast.success("List deleted"); + } catch (error) { + errorToast(error); + } finally { + setDeleting(false); + setDeletingList(null); + } + }; + + const handlePinList = async (id: string, pinned: boolean) => { + try { + setPinning(true); + setPinningList(id); + await axios.post( + `${HEY_API_URL}/lists/pin`, + { id, pin: !pinned }, + { headers: getAuthApiHeaders() } + ); + + queryClient.setQueryData( + [GET_LISTS_QUERY_KEY, currentAccount?.address], + (oldData) => + oldData?.map((list) => + list.id === id ? { ...list, pinned: !pinned } : list + ) + ); + toast.success("List pinned"); + } catch (error) { + errorToast(error); + } finally { + setPinning(false); + setPinningList(null); + } + }; + + return ( + +
+
{getAccount(account).slugWithPrefix}'s Lists
+ {account.address === currentAccount?.address && ( + + )} +
+
+
+ {isLoading ? ( + + ) : error ? ( + + ) : data?.length ? ( +
+ {data?.map((list) => ( +
+ + {account.address === currentAccount?.address && ( +
+ + + + +
+ )} +
+ ))} +
+ ) : ( + } + message={No lists found} + /> + )} +
+ setShowCreateModal(false)} + title="Create List" + > + + + + ); +}; + +export default Lists; diff --git a/apps/web/src/components/Account/Menu/AddToList.tsx b/apps/web/src/components/Account/Menu/AddToList.tsx new file mode 100644 index 000000000000..37ca1a6a57a5 --- /dev/null +++ b/apps/web/src/components/Account/Menu/AddToList.tsx @@ -0,0 +1,32 @@ +import { MenuItem } from "@headlessui/react"; +import { ListBulletIcon } from "@heroicons/react/24/outline"; +import type { Account } from "@hey/indexer"; +import cn from "@hey/ui/cn"; +import type { FC } from "react"; +import { useGlobalModalStateStore } from "src/store/non-persisted/useGlobalModalStateStore"; + +interface AddToListProps { + account: Account; +} + +const AddToList: FC = ({ account }) => { + const { setShowAddToListModal } = useGlobalModalStateStore(); + + return ( + + cn( + { "dropdown-active": focus }, + "m-2 flex cursor-pointer items-center space-x-2 rounded-lg px-2 py-1.5 text-sm" + ) + } + onClick={() => setShowAddToListModal(true, account)} + > + +
Add to list
+
+ ); +}; + +export default AddToList; diff --git a/apps/web/src/components/Account/Menu/Block.tsx b/apps/web/src/components/Account/Menu/Block.tsx new file mode 100644 index 000000000000..f763c8c57370 --- /dev/null +++ b/apps/web/src/components/Account/Menu/Block.tsx @@ -0,0 +1,41 @@ +import { MenuItem } from "@headlessui/react"; +import { NoSymbolIcon } from "@heroicons/react/24/outline"; +import getAccount from "@hey/helpers/getAccount"; +import stopEventPropagation from "@hey/helpers/stopEventPropagation"; +import type { Account } from "@hey/indexer"; +import cn from "@hey/ui/cn"; +import type { FC } from "react"; +import { useGlobalAlertStateStore } from "src/store/non-persisted/useGlobalAlertStateStore"; + +interface BlockProps { + account: Account; +} + +const Block: FC = ({ account }) => { + const { setShowBlockOrUnblockAlert } = useGlobalAlertStateStore(); + const isBlockedByMe = account.operations.isBlockedByMe.value; + + return ( + + cn( + { "dropdown-active": focus }, + "m-2 flex cursor-pointer items-center space-x-2 rounded-lg px-2 py-1.5 text-sm" + ) + } + onClick={(event) => { + stopEventPropagation(event); + setShowBlockOrUnblockAlert(true, account); + }} + > + +
+ {isBlockedByMe ? "Unblock" : "Block"}{" "} + {getAccount(account).slugWithPrefix} +
+
+ ); +}; + +export default Block; diff --git a/apps/web/src/components/Account/Menu/CopyAddress.tsx b/apps/web/src/components/Account/Menu/CopyAddress.tsx new file mode 100644 index 000000000000..c9370c44c766 --- /dev/null +++ b/apps/web/src/components/Account/Menu/CopyAddress.tsx @@ -0,0 +1,44 @@ +import { MenuItem } from "@headlessui/react"; +import { Leafwatch } from "@helpers/leafwatch"; +import { WalletIcon } from "@heroicons/react/24/outline"; +import { ACCOUNT } from "@hey/data/tracking"; +import stopEventPropagation from "@hey/helpers/stopEventPropagation"; +import cn from "@hey/ui/cn"; +import type { FC } from "react"; +import toast from "react-hot-toast"; +import { usePreferencesStore } from "src/store/persisted/usePreferencesStore"; + +interface CopyAddressProps { + address: string; +} + +const CopyAddress: FC = ({ address }) => { + const { developerMode } = usePreferencesStore(); + + if (!developerMode) { + return null; + } + + return ( + + cn( + { "dropdown-active": focus }, + "m-2 flex cursor-pointer items-center space-x-2 rounded-lg px-2 py-1.5 text-sm" + ) + } + onClick={async (event) => { + stopEventPropagation(event); + await navigator.clipboard.writeText(address); + toast.success("Address copied to clipboard!"); + Leafwatch.track(ACCOUNT.COPY_ACCOUNT_ADDRESS, { address }); + }} + > + +
Copy address
+
+ ); +}; + +export default CopyAddress; diff --git a/apps/web/src/components/Account/Menu/CopyID.tsx b/apps/web/src/components/Account/Menu/CopyID.tsx new file mode 100644 index 000000000000..beb7f29844b6 --- /dev/null +++ b/apps/web/src/components/Account/Menu/CopyID.tsx @@ -0,0 +1,44 @@ +import { MenuItem } from "@headlessui/react"; +import { Leafwatch } from "@helpers/leafwatch"; +import { HashtagIcon } from "@heroicons/react/24/outline"; +import { ACCOUNT } from "@hey/data/tracking"; +import stopEventPropagation from "@hey/helpers/stopEventPropagation"; +import cn from "@hey/ui/cn"; +import type { FC } from "react"; +import toast from "react-hot-toast"; +import { usePreferencesStore } from "src/store/persisted/usePreferencesStore"; + +interface CopyIDProps { + id: string; +} + +const CopyID: FC = ({ id }) => { + const { developerMode } = usePreferencesStore(); + + if (!developerMode) { + return null; + } + + return ( + + cn( + { "dropdown-active": focus }, + "m-2 flex cursor-pointer items-center space-x-2 rounded-lg px-2 py-1.5 text-sm" + ) + } + onClick={async (event) => { + stopEventPropagation(event); + await navigator.clipboard.writeText(id); + toast.success("ID copied to clipboard!"); + Leafwatch.track(ACCOUNT.COPY_ACCOUNT_ID, { accountId: id }); + }} + > + +
Copy ID
+
+ ); +}; + +export default CopyID; diff --git a/apps/web/src/components/Account/Menu/CopyLink.tsx b/apps/web/src/components/Account/Menu/CopyLink.tsx new file mode 100644 index 000000000000..5fccc18db315 --- /dev/null +++ b/apps/web/src/components/Account/Menu/CopyLink.tsx @@ -0,0 +1,43 @@ +import { MenuItem } from "@headlessui/react"; +import { Leafwatch } from "@helpers/leafwatch"; +import { LinkIcon } from "@heroicons/react/24/outline"; +import { ACCOUNT } from "@hey/data/tracking"; +import getAccount from "@hey/helpers/getAccount"; +import stopEventPropagation from "@hey/helpers/stopEventPropagation"; +import type { Account } from "@hey/indexer"; +import cn from "@hey/ui/cn"; +import type { FC } from "react"; +import toast from "react-hot-toast"; + +interface CopyLinkProps { + account: Account; +} + +const CopyLink: FC = ({ account }) => { + return ( + + cn( + { "dropdown-active": focus }, + "m-2 flex cursor-pointer items-center space-x-2 rounded-lg px-2 py-1.5 text-sm" + ) + } + onClick={async (event) => { + stopEventPropagation(event); + await navigator.clipboard.writeText( + `${location.origin}${getAccount(account).link}` + ); + toast.success("Link copied to clipboard!"); + Leafwatch.track(ACCOUNT.COPY_ACCOUNT_LINK, { + address: account.address + }); + }} + > + +
Copy link
+
+ ); +}; + +export default CopyLink; diff --git a/apps/web/src/components/Account/Menu/Report.tsx b/apps/web/src/components/Account/Menu/Report.tsx new file mode 100644 index 000000000000..b28d427bb6d6 --- /dev/null +++ b/apps/web/src/components/Account/Menu/Report.tsx @@ -0,0 +1,32 @@ +import { MenuItem } from "@headlessui/react"; +import { FlagIcon } from "@heroicons/react/24/outline"; +import type { Account } from "@hey/indexer"; +import cn from "@hey/ui/cn"; +import type { FC } from "react"; +import { useGlobalModalStateStore } from "src/store/non-persisted/useGlobalModalStateStore"; + +interface ReportProps { + account: Account; +} + +const Report: FC = ({ account }) => { + const { setShowReportAccountModal } = useGlobalModalStateStore(); + + return ( + + cn( + { "dropdown-active": focus }, + "m-2 flex cursor-pointer items-center space-x-2 rounded-lg px-2 py-1.5 text-sm" + ) + } + onClick={() => setShowReportAccountModal(true, account)} + > + +
Report account
+
+ ); +}; + +export default Report; diff --git a/apps/web/src/components/Account/Menu/index.tsx b/apps/web/src/components/Account/Menu/index.tsx new file mode 100644 index 000000000000..93e86beed7a7 --- /dev/null +++ b/apps/web/src/components/Account/Menu/index.tsx @@ -0,0 +1,56 @@ +import MenuTransition from "@components/Shared/MenuTransition"; +import { Menu, MenuButton, MenuItems } from "@headlessui/react"; +import { EllipsisVerticalIcon } from "@heroicons/react/24/outline"; +import stopEventPropagation from "@hey/helpers/stopEventPropagation"; +import type { Account } from "@hey/indexer"; +import type { FC } from "react"; +import { Fragment } from "react"; +import { useAccountStore } from "src/store/persisted/useAccountStore"; +import AddToList from "./AddToList"; +import Block from "./Block"; +import CopyAddress from "./CopyAddress"; +import CopyID from "./CopyID"; +import CopyLink from "./CopyLink"; +import Report from "./Report"; + +interface AccountMenuProps { + account: Account; +} + +const AccountMenu: FC = ({ account }) => { + const { currentAccount } = useAccountStore(); + + return ( + + + + + + + + + + {currentAccount && currentAccount?.address !== account.address ? ( + <> + + + + + ) : null} + + + + ); +}; + +export default AccountMenu; diff --git a/apps/web/src/components/Account/MutualFollowersOverview.tsx b/apps/web/src/components/Account/MutualFollowersOverview.tsx new file mode 100644 index 000000000000..566ce31743b7 --- /dev/null +++ b/apps/web/src/components/Account/MutualFollowersOverview.tsx @@ -0,0 +1,127 @@ +import MutualFollowers from "@components/Shared/Modal/MutualFollowers"; +import { Leafwatch } from "@helpers/leafwatch"; +import { ACCOUNT } from "@hey/data/tracking"; +import getAccount from "@hey/helpers/getAccount"; +import getAvatar from "@hey/helpers/getAvatar"; +import { type Follower, useFollowersYouKnowQuery } from "@hey/indexer"; +import { Modal, StackedAvatars } from "@hey/ui"; +import cn from "@hey/ui/cn"; +import { type FC, type ReactNode, useState } from "react"; +import { useAccountStore } from "src/store/persisted/useAccountStore"; + +interface MutualFollowersOverviewProps { + handle: string; + address: string; + viaPopover?: boolean; +} + +const MutualFollowersOverview: FC = ({ + handle, + address, + viaPopover = false +}) => { + const { currentAccount } = useAccountStore(); + const [showMutualFollowersModal, setShowMutualFollowersModal] = + useState(false); + + const { data, error, loading } = useFollowersYouKnowQuery({ + skip: !address || !currentAccount?.address, + variables: { + request: { + observer: currentAccount?.address, + target: address + } + } + }); + + const accounts = + (data?.followersYouKnow?.items.slice(0, 4) as Follower[]) || []; + + const Wrapper = ({ children }: { children: ReactNode }) => ( + + ); + + if (accounts.length === 0 || loading || error) { + return null; + } + + const accountOne = accounts[0]; + const accountTwo = accounts[1]; + const accountThree = accounts[2]; + + if (accounts?.length === 1) { + return ( + + {getAccount(accountOne).displayName} + + ); + } + + if (accounts?.length === 2) { + return ( + + {getAccount(accountOne).displayName} and + {getAccount(accountTwo).displayName} + + ); + } + + if (accounts?.length === 3) { + const calculatedCount = accounts.length - 3; + const isZero = calculatedCount === 0; + + return ( + + {getAccount(accountOne).displayName}, + + {getAccount(accountTwo).displayName} + {isZero ? " and " : ", "} + + {getAccount(accountThree).displayName} + {isZero ? null : ( + + and {calculatedCount} {calculatedCount === 1 ? "other" : "others"} + + )} + + ); + } + + return ( + + {getAccount(accountOne).displayName}, + {getAccount(accountTwo).displayName}, + {getAccount(accountThree).displayName} + and others + + ); +}; + +export default MutualFollowersOverview; diff --git a/apps/web/src/components/Account/ScamWarning.tsx b/apps/web/src/components/Account/ScamWarning.tsx new file mode 100644 index 000000000000..37f177127764 --- /dev/null +++ b/apps/web/src/components/Account/ScamWarning.tsx @@ -0,0 +1,43 @@ +import Markup from "@components/Shared/Markup"; +import getMentions from "@hey/helpers/getMentions"; +import getMisuseDetails from "@hey/helpers/getMisuseDetails"; +import { Card } from "@hey/ui"; +import type { FC } from "react"; + +interface ScamWarningProps { + accountId: string; +} + +const ScamWarning: FC = ({ accountId }) => { + const misuseDetails = getMisuseDetails(accountId); + + if (!misuseDetails) { + return null; + } + + const { description, identifiedOn, type } = misuseDetails; + + return ( + +
+

Account is marked as {type.toLowerCase()}!

+
+ {description && ( + + {description} + + )} + {identifiedOn && ( +

+ Identified on: {identifiedOn} +

+ )} +
+ ); +}; + +export default ScamWarning; diff --git a/apps/web/src/components/Account/Shimmer.tsx b/apps/web/src/components/Account/Shimmer.tsx new file mode 100644 index 000000000000..47372caa010a --- /dev/null +++ b/apps/web/src/components/Account/Shimmer.tsx @@ -0,0 +1,62 @@ +import PostsShimmer from "@components/Shared/Shimmer/PostsShimmer"; +import { GridItemEight, GridItemFour, GridLayout } from "@hey/ui"; +import type { FC } from "react"; + +const AccountPageShimmer: FC = () => { + return ( + <> +
+
+
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: 2 }).map((_, index) => ( +
+
+
+
+ ))} +
+
+
+ {Array.from({ length: 2 }).map((_, index) => ( +
+
+
+
+ ))} +
+
+
+ + +
+ {Array.from({ length: 4 }).map((_, index) => ( +
+ ))} +
+ + + + + ); +}; + +export default AccountPageShimmer; diff --git a/apps/web/src/components/Account/StaffTool.tsx b/apps/web/src/components/Account/StaffTool.tsx new file mode 100644 index 000000000000..445286d67431 --- /dev/null +++ b/apps/web/src/components/Account/StaffTool.tsx @@ -0,0 +1,20 @@ +import Suspend from "@components/Shared/Account/Suspend"; +import type { Account } from "@hey/indexer"; +import type { FC } from "react"; + +interface StaffToolProps { + account: Account; +} + +const StaffTool: FC = ({ account }) => { + return ( +
+
Staff Tool
+
+ +
+
+ ); +}; + +export default StaffTool; diff --git a/apps/web/src/components/Account/SuspendedDetails.tsx b/apps/web/src/components/Account/SuspendedDetails.tsx new file mode 100644 index 000000000000..046eac646777 --- /dev/null +++ b/apps/web/src/components/Account/SuspendedDetails.tsx @@ -0,0 +1,39 @@ +import Slug from "@components/Shared/Slug"; +import { STATIC_IMAGES_URL } from "@hey/data/constants"; +import getAccount from "@hey/helpers/getAccount"; +import type { Account } from "@hey/indexer"; +import { H3, Image } from "@hey/ui"; +import type { FC } from "react"; + +interface SuspendedDetailsProps { + account: Account; +} + +const SuspendedDetails: FC = ({ account }) => { + const profileData = getAccount(account); + + return ( +
+
+ {account.address} +
+
+

Suspended

+
+ +
+
+
+ ); +}; + +export default SuspendedDetails; diff --git a/apps/web/src/components/Account/UpdateTheme.tsx b/apps/web/src/components/Account/UpdateTheme.tsx new file mode 100644 index 000000000000..2c6a03c1da4c --- /dev/null +++ b/apps/web/src/components/Account/UpdateTheme.tsx @@ -0,0 +1,92 @@ +import accountThemeFonts, { Font } from "@helpers/accountThemeFonts"; +import errorToast from "@helpers/errorToast"; +import { getAuthApiHeaders } from "@helpers/getAuthApiHeaders"; +import { HEY_API_URL } from "@hey/data/constants"; +import { GET_PREFERENCES_QUERY_KEY } from "@hey/helpers/api/getPreferences"; +import camelCaseToReadable from "@hey/helpers/camelCaseToReadable"; +import { Button, Select } from "@hey/ui"; +import { useQueryClient } from "@tanstack/react-query"; +import axios from "axios"; +import { type FC, useState } from "react"; +import toast from "react-hot-toast"; +import { useAccountThemeStore } from "src/store/persisted/useAccountThemeStore"; + +const UpdateTheme: FC = () => { + const { theme, setTheme } = useAccountThemeStore(); + const [updating, setUpdating] = useState(false); + const queryClient = useQueryClient(); + + const handleResetTheme = async () => { + setUpdating(true); + try { + await axios.post(`${HEY_API_URL}/preferences/theme/reset`, undefined, { + headers: getAuthApiHeaders() + }); + queryClient.invalidateQueries({ queryKey: [GET_PREFERENCES_QUERY_KEY] }); + setTheme(null); + toast.success("Account theme reset"); + } catch (error) { + errorToast(error); + } finally { + setUpdating(false); + } + }; + + const handleUpdateTheme = async () => { + setUpdating(true); + try { + await axios.post(`${HEY_API_URL}/preferences/theme/update`, theme, { + headers: getAuthApiHeaders() + }); + queryClient.invalidateQueries({ queryKey: [GET_PREFERENCES_QUERY_KEY] }); + toast.success("Account theme updated"); + } catch (error) { + errorToast(error); + } finally { + setUpdating(false); + } + }; + + return ( +
+
+
Font style
+ setPrimaryType(value)} + options={primaryOptions} + /> + Leafwatch.track(PUBLICATION.NEW.ATTACHMENT.UPLOAD_IMAGES)} - onChange={handleAttachment} - disabled={attachments.length >= 4} - /> - - - clsx( - { 'dropdown-active': active }, - '!flex rounded-lg gap-1 space-x-1 items-center cursor-pointer menu-item' - ) - } - htmlFor={`video_${id}`} - > - - Upload video - Leafwatch.track(PUBLICATION.NEW.ATTACHMENT.UPLOAD_VIDEO)} - onChange={handleAttachment} - disabled={attachments.length >= 4} - /> - - - clsx( - { 'dropdown-active': active }, - '!flex rounded-lg gap-1 space-x-1 items-center cursor-pointer menu-item' - ) - } - htmlFor={`audio_${id}`} + {isUploading ? ( + + ) : ( + + )} + + + - - Upload audio - Leafwatch.track(PUBLICATION.NEW.ATTACHMENT.UPLOAD_AUDIO)} - onChange={handleAttachment} - disabled={attachments.length >= 4} - /> - - - - + + cn( + { "dropdown-active": focus }, + { "opacity-50": disableImageUpload() }, + "menu-item !flex cursor-pointer items-center gap-1 space-x-1 rounded-lg" + ) + } + disabled={disableImageUpload()} + htmlFor={`image_${id}`} + > + + Upload image(s) + + + + cn( + { "dropdown-active": focus }, + { "opacity-50": disableOtherUpload }, + "menu-item !flex cursor-pointer items-center gap-1 space-x-1 rounded-lg" + ) + } + disabled={disableOtherUpload} + htmlFor={`video_${id}`} + > + + Upload video + + + + cn( + { "dropdown-active": focus }, + { "opacity-50": disableOtherUpload }, + "menu-item !flex cursor-pointer items-center gap-1 space-x-1 rounded-lg" + ) + } + disabled={disableOtherUpload} + htmlFor={`audio_${id}`} + > + + Upload audio + + + + + + ); }; diff --git a/apps/web/src/components/Composer/Actions/CollectSettings/AmountConfig.tsx b/apps/web/src/components/Composer/Actions/CollectSettings/AmountConfig.tsx new file mode 100644 index 000000000000..e1e71f71e4af --- /dev/null +++ b/apps/web/src/components/Composer/Actions/CollectSettings/AmountConfig.tsx @@ -0,0 +1,91 @@ +import ToggleWithHelper from "@components/Shared/ToggleWithHelper"; +import { CurrencyDollarIcon } from "@heroicons/react/24/outline"; +import { DEFAULT_COLLECT_TOKEN, STATIC_IMAGES_URL } from "@hey/data/constants"; +import { CollectOpenActionModuleType } from "@hey/lens"; +import type { CollectModuleType } from "@hey/types/hey"; +import { Input, Select } from "@hey/ui"; +import type { FC } from "react"; +import { useCollectModuleStore } from "src/store/non-persisted/post/useCollectModuleStore"; +import { useAccountStore } from "src/store/persisted/useAccountStore"; +import { useAllowedTokensStore } from "src/store/persisted/useAllowedTokensStore"; + +interface AmountConfigProps { + setCollectType: (data: CollectModuleType) => void; +} + +const AmountConfig: FC = ({ setCollectType }) => { + const { currentAccount } = useAccountStore(); + const { collectModule } = useCollectModuleStore((state) => state); + const { allowedTokens } = useAllowedTokensStore(); + + const enabled = Boolean(collectModule.amount?.value); + + return ( +
+ } + on={enabled} + setOn={() => { + setCollectType({ + amount: enabled + ? null + : { currency: DEFAULT_COLLECT_TOKEN, value: "1" }, + recipients: enabled + ? [] + : [{ recipient: currentAccount?.owner, split: 100 }], + type: enabled + ? CollectOpenActionModuleType.SimpleCollectOpenActionModule + : CollectOpenActionModuleType.MultirecipientFeeCollectOpenActionModule + }); + }} + /> + {collectModule.amount?.value ? ( +
+
+ { + setCollectType({ + amount: { + currency: collectModule.amount?.currency, + value: event.target.value ? event.target.value : "0" + } + }); + }} + placeholder="0.5" + type="number" + value={Number.parseFloat(collectModule.amount.value)} + /> +
+
Select currency
+ { - setAmount(event.target.value ? event.target.value : '0'); - }} - /> -
-
Select Currency
- -
-
-
-
- - Mirror referral reward -
-
- Share your collect fee with people who amplify your content -
-
- { - setReferralFee(event.target.value ? event.target.value : '0'); - }} - /> -
-
-
+
+ {collectModule.type !== null ? ( + <> +
+ + {collectModule.amount?.value ? ( + <> + + + ) : null} + + +
- {selectedCollectModule !== FreeCollectModule && amount && ( - <> -
-
- - Limited edition -
-
- setCollectLimit(collectLimit ? null : '1')} /> -
- Make the collects exclusive -
-
- {collectLimit ? ( -
- { - setCollectLimit(event.target.value ? event.target.value : '1'); - }} - /> -
- ) : null} -
-
-
- - Time limit -
-
- setHasTimeLimit(!hasTimeLimit)} /> -
- Limit collecting to the first 24h -
-
-
- - )} -
-
- - Who can collect -
-
- setFollowerOnly(!followerOnly)} /> -
- Only followers can collect -
-
+
+
+
-
- )} -
+
+ + ) : null} +
+ -
-
+ ); }; diff --git a/apps/web/src/components/Composer/Actions/CollectSettings/CollectLimitConfig.tsx b/apps/web/src/components/Composer/Actions/CollectSettings/CollectLimitConfig.tsx new file mode 100644 index 000000000000..5bf13a62ddd5 --- /dev/null +++ b/apps/web/src/components/Composer/Actions/CollectSettings/CollectLimitConfig.tsx @@ -0,0 +1,51 @@ +import ToggleWithHelper from "@components/Shared/ToggleWithHelper"; +import { StarIcon } from "@heroicons/react/24/outline"; +import type { CollectModuleType } from "@hey/types/hey"; +import { Input } from "@hey/ui"; +import type { FC } from "react"; +import { useCollectModuleStore } from "src/store/non-persisted/post/useCollectModuleStore"; + +interface CollectLimitConfigProps { + setCollectType: (data: CollectModuleType) => void; +} + +const CollectLimitConfig: FC = ({ + setCollectType +}) => { + const { collectModule } = useCollectModuleStore((state) => state); + + return ( +
+ } + on={Boolean(collectModule.collectLimit)} + setOn={() => + setCollectType({ + collectLimit: collectModule.collectLimit ? null : "1" + }) + } + /> + {collectModule.collectLimit ? ( +
+ { + setCollectType({ + collectLimit: event.target.value ? event.target.value : "1" + }); + }} + placeholder="5" + type="number" + value={collectModule.collectLimit} + /> +
+ ) : null} +
+ ); +}; + +export default CollectLimitConfig; diff --git a/apps/web/src/components/Composer/Actions/CollectSettings/FollowersConfig.tsx b/apps/web/src/components/Composer/Actions/CollectSettings/FollowersConfig.tsx new file mode 100644 index 000000000000..944f1a7f29db --- /dev/null +++ b/apps/web/src/components/Composer/Actions/CollectSettings/FollowersConfig.tsx @@ -0,0 +1,29 @@ +import ToggleWithHelper from "@components/Shared/ToggleWithHelper"; +import { UserGroupIcon } from "@heroicons/react/24/outline"; +import type { CollectModuleType } from "@hey/types/hey"; +import type { FC } from "react"; +import { useCollectModuleStore } from "src/store/non-persisted/post/useCollectModuleStore"; + +interface FollowersConfigProps { + setCollectType: (data: CollectModuleType) => void; +} + +const FollowersConfig: FC = ({ setCollectType }) => { + const { collectModule } = useCollectModuleStore((state) => state); + + return ( +
+ } + on={collectModule.followerOnly || false} + setOn={() => + setCollectType({ followerOnly: !collectModule.followerOnly }) + } + /> +
+ ); +}; + +export default FollowersConfig; diff --git a/apps/web/src/components/Composer/Actions/CollectSettings/ReferralConfig.tsx b/apps/web/src/components/Composer/Actions/CollectSettings/ReferralConfig.tsx new file mode 100644 index 000000000000..94dddd9c46f4 --- /dev/null +++ b/apps/web/src/components/Composer/Actions/CollectSettings/ReferralConfig.tsx @@ -0,0 +1,51 @@ +import ToggleWithHelper from "@components/Shared/ToggleWithHelper"; +import { ArrowsRightLeftIcon } from "@heroicons/react/24/outline"; +import { CollectOpenActionModuleType } from "@hey/lens"; +import type { CollectModuleType } from "@hey/types/hey"; +import { RangeSlider } from "@hey/ui"; +import type { FC } from "react"; +import { useCollectModuleStore } from "src/store/non-persisted/post/useCollectModuleStore"; + +interface ReferralConfigProps { + setCollectType: (data: CollectModuleType) => void; +} + +const ReferralConfig: FC = ({ setCollectType }) => { + const { collectModule } = useCollectModuleStore((state) => state); + + return ( +
+ } + on={Boolean(collectModule.referralFee)} + setOn={() => + setCollectType({ + referralFee: collectModule.referralFee ? 0 : 25, + type: collectModule.recipients?.length + ? CollectOpenActionModuleType.MultirecipientFeeCollectOpenActionModule + : CollectOpenActionModuleType.SimpleCollectOpenActionModule + }) + } + /> + {collectModule.referralFee ? ( +
+
Referral fee
+ + setCollectType({ referralFee: Number(value[0]) }) + } + /> +
+ ) : null} +
+ ); +}; + +export default ReferralConfig; diff --git a/apps/web/src/components/Composer/Actions/CollectSettings/SplitConfig.tsx b/apps/web/src/components/Composer/Actions/CollectSettings/SplitConfig.tsx new file mode 100644 index 000000000000..8650440c5faa --- /dev/null +++ b/apps/web/src/components/Composer/Actions/CollectSettings/SplitConfig.tsx @@ -0,0 +1,198 @@ +import SearchAccounts from "@components/Shared/SearchAccounts"; +import ToggleWithHelper from "@components/Shared/ToggleWithHelper"; +import { + ArrowsRightLeftIcon, + PlusIcon, + UsersIcon, + XCircleIcon +} from "@heroicons/react/24/outline"; +import { ADDRESS_PLACEHOLDER } from "@hey/data/constants"; +import splitNumber from "@hey/helpers/splitNumber"; +import type { CollectModuleType } from "@hey/types/hey"; +import { Button, H6, Input } from "@hey/ui"; +import type { FC } from "react"; +import { useState } from "react"; +import { useCollectModuleStore } from "src/store/non-persisted/post/useCollectModuleStore"; +import { useAccountStore } from "src/store/persisted/useAccountStore"; +import { isAddress } from "viem"; + +interface SplitConfigProps { + isRecipientsDuplicated: () => boolean; + setCollectType: (data: CollectModuleType) => void; +} + +const SplitConfig: FC = ({ + isRecipientsDuplicated, + setCollectType +}) => { + const { currentAccount } = useAccountStore(); + const { collectModule } = useCollectModuleStore((state) => state); + + const currentAddress = currentAccount?.owner || ""; + const recipients = collectModule.recipients || []; + const [isToggleOn, setIsToggleOn] = useState( + recipients.length > 1 || + (recipients.length === 1 && recipients[0].recipient !== currentAddress) + ); + const splitTotal = recipients.reduce((acc, curr) => acc + curr.split, 0); + + const handleSplitEvenly = () => { + const equalSplits = splitNumber(100, recipients.length); + const splits = recipients.map((recipient, i) => { + return { + recipient: recipient.recipient, + split: equalSplits[i] + }; + }); + setCollectType({ + recipients: [...splits] + }); + }; + + const onChangeRecipientOrSplit = ( + index: number, + value: string, + type: "recipient" | "split" + ) => { + const getRecipients = (value: string) => { + return recipients.map((recipient, i) => { + if (i === index) { + return { + ...recipient, + [type]: type === "split" ? Number.parseInt(value) : value + }; + } + return recipient; + }); + }; + + setCollectType({ recipients: getRecipients(value) }); + }; + + const updateRecipient = (index: number, value: string) => { + onChangeRecipientOrSplit(index, value, "recipient"); + }; + + const handleRemoveRecipient = (index: number) => { + const updatedRecipients = recipients.filter((_, i) => i !== index); + if (updatedRecipients.length === 0) { + setCollectType({ + recipients: [{ recipient: currentAddress, split: 100 }] + }); + setIsToggleOn(false); + } else { + setCollectType({ recipients: updatedRecipients }); + } + }; + + const toggleSplit = () => { + setCollectType({ + recipients: [{ recipient: currentAddress, split: 100 }] + }); + setIsToggleOn(!isToggleOn); + }; + + return ( +
+ } + on={isToggleOn} + setOn={toggleSplit} + /> + {isToggleOn ? ( +
+
+ {recipients.map((recipient, index) => ( +
+ 0 && + !isAddress(recipient.recipient) + } + hideDropdown={isAddress(recipient.recipient)} + onChange={(event) => + updateRecipient(index, event.target.value) + } + onAccountSelected={(account) => + updateRecipient(index, account.owner) + } + placeholder={`${ADDRESS_PLACEHOLDER} or wagmi`} + value={recipient.recipient} + /> +
+ + onChangeRecipientOrSplit( + index, + event.target.value, + "split" + ) + } + placeholder="5" + type="number" + value={recipient.split} + /> +
+ +
+ ))} +
+
+ {recipients.length >= 4 ? ( +
+ ) : ( + + )} + +
+ {splitTotal > 100 ? ( +
+ Splits cannot exceed 100%. Total: {splitTotal}% +
+ ) : null} + {splitTotal < 100 ? ( +
+ Splits cannot be less than 100%. Total: {splitTotal}% +
+ ) : null} + {isRecipientsDuplicated() ? ( +
Duplicate recipient address found
+ ) : null} +
+ ) : null} +
+ ); +}; + +export default SplitConfig; diff --git a/apps/web/src/components/Composer/Actions/CollectSettings/TimeLimitConfig.tsx b/apps/web/src/components/Composer/Actions/CollectSettings/TimeLimitConfig.tsx new file mode 100644 index 000000000000..1036cff0b0e7 --- /dev/null +++ b/apps/web/src/components/Composer/Actions/CollectSettings/TimeLimitConfig.tsx @@ -0,0 +1,59 @@ +import ToggleWithHelper from "@components/Shared/ToggleWithHelper"; +import { ClockIcon } from "@heroicons/react/24/outline"; +import formatDate from "@hey/helpers/datetime/formatDate"; +import getNumberOfDaysFromDate from "@hey/helpers/datetime/getNumberOfDaysFromDate"; +import getTimeAddedNDay from "@hey/helpers/datetime/getTimeAddedNDay"; +import type { CollectModuleType } from "@hey/types/hey"; +import { RangeSlider } from "@hey/ui"; +import type { FC } from "react"; +import { useCollectModuleStore } from "src/store/non-persisted/post/useCollectModuleStore"; + +interface TimeLimitConfigProps { + setCollectType: (data: CollectModuleType) => void; +} + +const TimeLimitConfig: FC = ({ setCollectType }) => { + const { collectModule } = useCollectModuleStore((state) => state); + + return ( +
+ } + on={Boolean(collectModule.endsAt)} + setOn={() => + setCollectType({ + endsAt: collectModule.endsAt ? null : getTimeAddedNDay(1) + }) + } + /> + {collectModule.endsAt ? ( +
+
+ Number of days -{" "} + + {formatDate(collectModule.endsAt, "MMM D, YYYY - hh:mm:ss A")} + +
+ + setCollectType({ endsAt: getTimeAddedNDay(Number(value[0])) }) + } + /> +
+ ) : null} +
+ ); +}; + +export default TimeLimitConfig; diff --git a/apps/web/src/components/Composer/Actions/CollectSettings/index.tsx b/apps/web/src/components/Composer/Actions/CollectSettings/index.tsx index 61fc58883112..b92ace01f925 100644 --- a/apps/web/src/components/Composer/Actions/CollectSettings/index.tsx +++ b/apps/web/src/components/Composer/Actions/CollectSettings/index.tsx @@ -1,43 +1,36 @@ -import { Modal } from '@components/UI/Modal'; -import { Tooltip } from '@components/UI/Tooltip'; -import GetModuleIcon from '@components/utils/GetModuleIcon'; -import { CashIcon } from '@heroicons/react/outline'; -import { getModule } from '@lib/getModule'; -import { Leafwatch } from '@lib/leafwatch'; -import { motion } from 'framer-motion'; -import type { FC } from 'react'; -import { useState } from 'react'; -import { useCollectModuleStore } from 'src/store/collect-module'; -import { PUBLICATION } from 'src/tracking'; - -import CollectForm from './CollectForm'; +import { ShoppingBagIcon } from "@heroicons/react/24/outline"; +import { Modal, Tooltip } from "@hey/ui"; +import type { FC } from "react"; +import { useState } from "react"; +import { useCollectModuleStore } from "src/store/non-persisted/post/useCollectModuleStore"; +import { usePostLicenseStore } from "src/store/non-persisted/post/usePostLicenseStore"; +import CollectForm from "./CollectForm"; const CollectSettings: FC = () => { - const selectedCollectModule = useCollectModuleStore((state) => state.selectedCollectModule); + const { reset } = useCollectModuleStore((state) => state); + const { setLicense } = usePostLicenseStore(); const [showModal, setShowModal] = useState(false); return ( <> - - + } + onClose={() => { + setShowModal(false); + setLicense(null); + reset(); + }} show={showModal} - onClose={() => setShowModal(false)} + title="Collect Settings" > diff --git a/apps/web/src/components/Composer/Actions/Gif/Categories.tsx b/apps/web/src/components/Composer/Actions/Gif/Categories.tsx new file mode 100644 index 000000000000..7eef90e1a3c1 --- /dev/null +++ b/apps/web/src/components/Composer/Actions/Gif/Categories.tsx @@ -0,0 +1,58 @@ +import { GIPHY_KEY } from "@hey/data/constants"; +import type { Category } from "@hey/types/giphy"; +import { H5 } from "@hey/ui"; +import { useQuery } from "@tanstack/react-query"; +import axios from "axios"; +import type { Dispatch, FC, SetStateAction } from "react"; + +const GET_GIPHY_CATEGORIES_QUERY_KEY = "getGiphyCategories"; + +interface CategoriesProps { + setSearchText: Dispatch>; +} + +const Categories: FC = ({ setSearchText }) => { + const getGiphyCategories = async () => { + try { + const { data } = await axios.get( + "https://api.giphy.com/v1/gifs/categories", + { params: { api_key: GIPHY_KEY } } + ); + + return data.data; + } catch { + return []; + } + }; + + const { data: categories } = useQuery({ + queryFn: getGiphyCategories, + queryKey: [GET_GIPHY_CATEGORIES_QUERY_KEY] + }); + + return ( +
+ {categories?.map((category: Category) => ( + + ))} +
+ ); +}; + +export default Categories; diff --git a/apps/web/src/components/Composer/Actions/Gif/GifSelector.tsx b/apps/web/src/components/Composer/Actions/Gif/GifSelector.tsx new file mode 100644 index 000000000000..1cfe22e7180b --- /dev/null +++ b/apps/web/src/components/Composer/Actions/Gif/GifSelector.tsx @@ -0,0 +1,56 @@ +import { STATIC_IMAGES_URL } from "@hey/data/constants"; +import type { IGif } from "@hey/types/giphy"; +import { Input } from "@hey/ui"; +import { useDebounce } from "@uidotdev/usehooks"; +import type { Dispatch, FC, SetStateAction } from "react"; +import { useState } from "react"; +import Categories from "./Categories"; +import Gifs from "./Gifs"; + +interface GifSelectorProps { + setGifAttachment: (gif: IGif) => void; + setShowModal: Dispatch>; +} + +const GifSelector: FC = ({ + setGifAttachment, + setShowModal +}) => { + const [searchText, setSearchText] = useState(""); + const debouncedGifInput = useDebounce(searchText, 500); + + return ( + <> +
+ setSearchText(event.target.value)} + placeholder="Search for GIFs" + type="text" + value={searchText} + /> +
+
+ {debouncedGifInput ? ( + + ) : ( + + )} +
+
+ Powered by + Giphy +
+ + ); +}; + +export default GifSelector; diff --git a/apps/web/src/components/Composer/Actions/Gif/Gifs.tsx b/apps/web/src/components/Composer/Actions/Gif/Gifs.tsx new file mode 100644 index 000000000000..c90112d3b270 --- /dev/null +++ b/apps/web/src/components/Composer/Actions/Gif/Gifs.tsx @@ -0,0 +1,81 @@ +import { GIPHY_KEY } from "@hey/data/constants"; +import type { IGif } from "@hey/types/giphy"; +import { useQuery } from "@tanstack/react-query"; +import axios from "axios"; +import type { Dispatch, FC, SetStateAction } from "react"; + +const GET_GIFS_QUERY_KEY = "getGifs"; + +interface GifsProps { + debouncedGifInput: string; + setGifAttachment: (gif: IGif) => void; + setSearchText: Dispatch>; + setShowModal: Dispatch>; +} + +const Gifs: FC = ({ + debouncedGifInput, + setGifAttachment, + setSearchText, + setShowModal +}) => { + const handleSelectGif = (item: IGif) => { + setGifAttachment(item); + setSearchText(""); + setShowModal(false); + }; + + const getGifs = async (input: string): Promise => { + try { + const { data } = await axios.get("https://api.giphy.com/v1/gifs/search", { + params: { api_key: GIPHY_KEY, limit: 48, q: input } + }); + + return data.data; + } catch { + return []; + } + }; + + const { data: gifs, isFetching } = useQuery({ + enabled: Boolean(debouncedGifInput), + queryFn: () => getGifs(debouncedGifInput), + queryKey: [GET_GIFS_QUERY_KEY, debouncedGifInput] + }); + + if (isFetching) { + return ( +
+ {Array.from(Array(12).keys()).map((key) => ( +
+ ))} +
+ ); + } + + return ( +
+ {gifs?.map((gif: IGif) => ( + + ))} +
+ ); +}; + +export default Gifs; diff --git a/apps/web/src/components/Composer/Actions/Gif/index.tsx b/apps/web/src/components/Composer/Actions/Gif/index.tsx new file mode 100644 index 000000000000..0d133749c0d5 --- /dev/null +++ b/apps/web/src/components/Composer/Actions/Gif/index.tsx @@ -0,0 +1,57 @@ +import { Leafwatch } from "@helpers/leafwatch"; +import { GifIcon } from "@heroicons/react/24/outline"; +import { POST } from "@hey/data/tracking"; +import type { IGif } from "@hey/types/giphy"; +import { Modal, Tooltip } from "@hey/ui"; +import cn from "@hey/ui/cn"; +import type { FC } from "react"; +import { useState } from "react"; +import { usePostAttachmentStore } from "src/store/non-persisted/post/usePostAttachmentStore"; +import GifSelector from "./GifSelector"; + +interface GifProps { + setGifAttachment: (gif: IGif) => void; +} + +const Gif: FC = ({ setGifAttachment }) => { + const { attachments } = usePostAttachmentStore((state) => state); + const [showModal, setShowModal] = useState(false); + const disable = + attachments.length > 0 && + (attachments.some((attachment) => attachment.type === "Image") + ? attachments.length >= 4 + : true); + + return ( + <> + + + + setShowModal(false)} + show={showModal} + title="Select GIF" + > + + + + ); +}; + +export default Gif; diff --git a/apps/web/src/components/Composer/Actions/Giphy/GifSelector.tsx b/apps/web/src/components/Composer/Actions/Giphy/GifSelector.tsx deleted file mode 100644 index 09354c0e7087..000000000000 --- a/apps/web/src/components/Composer/Actions/Giphy/GifSelector.tsx +++ /dev/null @@ -1,101 +0,0 @@ -import { Input } from '@components/UI/Input'; -import { useDebounce } from '@components/utils/hooks/useDebounce'; -import type { ICategory } from '@giphy/js-fetch-api'; -import { GiphyFetch } from '@giphy/js-fetch-api'; -import type { IGif } from '@giphy/js-types'; -import { Grid } from '@giphy/react-components'; -import type { ChangeEvent, Dispatch, FC } from 'react'; -import { useEffect, useState } from 'react'; - -const giphyFetch = new GiphyFetch('sXpGFDGZs0Dv1mmNFvYaGUvYwKX0PWIh'); - -interface Props { - setGifAttachment: (gif: IGif) => void; - setShowModal: Dispatch; -} - -const GifSelector: FC = ({ setShowModal, setGifAttachment }) => { - const [categories, setCategories] = useState([]); - const [debouncedGifInput, setDebouncedGifInput] = useState(''); - const [searchText, setSearchText] = useState(''); - - const fetchGiphyCategories = async () => { - const { data } = await giphyFetch.categories(); - // TODO: we can persist this categories - setCategories(data); - }; - - const onSelectGif = (item: IGif) => { - setGifAttachment(item); - setDebouncedGifInput(''); - setSearchText(''); - setShowModal(false); - }; - - useDebounce( - () => { - setSearchText(debouncedGifInput); - }, - 1000, - [debouncedGifInput] - ); - - useEffect(() => { - fetchGiphyCategories(); - }, []); - - const fetchGifs = (offset: number) => { - return giphyFetch.search(searchText, { offset, limit: 10 }); - }; - - const handleSearch = (evt: ChangeEvent) => { - const keyword = evt.target.value; - setDebouncedGifInput(keyword); - }; - - return ( -
-
- -
-
- {debouncedGifInput ? ( - onSelectGif(item)} - fetchGifs={fetchGifs} - width={498} - hideAttribution - columns={3} - noResultsMessage={
No GIFs found.
} - noLink - key={searchText} - /> - ) : ( -
- {categories.map((category) => ( - - ))} -
- )} -
-
- ); -}; - -export default GifSelector; diff --git a/apps/web/src/components/Composer/Actions/Giphy/index.tsx b/apps/web/src/components/Composer/Actions/Giphy/index.tsx deleted file mode 100644 index 69726627a093..000000000000 --- a/apps/web/src/components/Composer/Actions/Giphy/index.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import Loader from '@components/Shared/Loader'; -import { Modal } from '@components/UI/Modal'; -import { Tooltip } from '@components/UI/Tooltip'; -import type { IGif } from '@giphy/js-types'; -import { PhotographIcon } from '@heroicons/react/outline'; -import { Leafwatch } from '@lib/leafwatch'; -import { motion } from 'framer-motion'; -import dynamic from 'next/dynamic'; -import type { FC } from 'react'; -import { useState } from 'react'; -import { PUBLICATION } from 'src/tracking'; - -const GifSelector = dynamic(() => import('./GifSelector'), { - loading: () => -}); - -interface Props { - setGifAttachment: (gif: IGif) => void; -} - -const Giphy: FC = ({ setGifAttachment }) => { - const [showModal, setShowModal] = useState(false); - - return ( - <> - - { - setShowModal(!showModal); - Leafwatch.track(PUBLICATION.NEW.OPEN_GIF); - }} - aria-label="Choose GIFs" - > -
- - - - - - -
-
-
- } - show={showModal} - onClose={() => setShowModal(false)} - > - - - - ); -}; - -export default Giphy; diff --git a/apps/web/src/components/Composer/Actions/LivestreamSettings/LivestreamEditor.tsx b/apps/web/src/components/Composer/Actions/LivestreamSettings/LivestreamEditor.tsx new file mode 100644 index 000000000000..193350b870de --- /dev/null +++ b/apps/web/src/components/Composer/Actions/LivestreamSettings/LivestreamEditor.tsx @@ -0,0 +1,170 @@ +import Video from "@components/Shared/Video"; +import { getAuthApiHeaders } from "@helpers/getAuthApiHeaders"; +import { + ClipboardDocumentIcon, + SignalIcon, + VideoCameraIcon, + VideoCameraSlashIcon +} from "@heroicons/react/24/outline"; +import { XCircleIcon } from "@heroicons/react/24/solid"; +import { HEY_API_URL } from "@hey/data/constants"; +import { Card, Spinner, Tooltip } from "@hey/ui"; +import { getSrc } from "@livepeer/react/external"; +import axios from "axios"; +import type { FC, ReactNode } from "react"; +import { useState } from "react"; +import toast from "react-hot-toast"; +import { usePostLiveStore } from "src/store/non-persisted/post/usePostLiveStore"; + +interface WrapperProps { + children: ReactNode; +} + +const Wrapper: FC = ({ children }) => { + return ( + +
{children}
+
+ ); +}; + +const LivestreamEditor: FC = () => { + const { + liveVideoConfig, + resetLiveVideoConfig, + setLiveVideoConfig, + setShowLiveVideoEditor + } = usePostLiveStore(); + + const [screen, setScreen] = useState<"create" | "record">("create"); + const [creating, setCreating] = useState(false); + + const handleCreateLiveStream = async (record: boolean) => { + try { + setCreating(true); + const { data } = await axios.post( + `${HEY_API_URL}/live/create`, + { record }, + { headers: getAuthApiHeaders() } + ); + setLiveVideoConfig({ + id: data.result.id, + playbackId: data.result.playbackId, + streamKey: data.result.streamKey + }); + } catch { + toast.error("Error creating live stream"); + } finally { + setCreating(false); + } + }; + + return ( + +
+
+ + Go Live +
+
+ + + +
+
+
+ {creating ? ( + + +
Creating Live Stream...
+
+ ) : liveVideoConfig.playbackId.length > 0 ? ( + <> + +
+ Stream URL: +
rtmp://rtmp.hey.xyz/live
+ +
+
+ Stream Key: +
{liveVideoConfig.streamKey}
+ +
+
+
+
+ ); +}; + +export default LivestreamEditor; diff --git a/apps/web/src/components/Composer/Actions/LivestreamSettings/index.tsx b/apps/web/src/components/Composer/Actions/LivestreamSettings/index.tsx new file mode 100644 index 000000000000..9f524bed1527 --- /dev/null +++ b/apps/web/src/components/Composer/Actions/LivestreamSettings/index.tsx @@ -0,0 +1,34 @@ +import { VideoCameraIcon } from "@heroicons/react/24/outline"; +import { Tooltip } from "@hey/ui"; +import cn from "@hey/ui/cn"; +import type { FC } from "react"; +import { usePostAttachmentStore } from "src/store/non-persisted/post/usePostAttachmentStore"; +import { usePostLiveStore } from "src/store/non-persisted/post/usePostLiveStore"; + +const LivestreamSettings: FC = () => { + const { resetLiveVideoConfig, setShowLiveVideoEditor, showLiveVideoEditor } = + usePostLiveStore(); + const { attachments } = usePostAttachmentStore((state) => state); + const disable = attachments.length > 0; + + return ( + + + + ); +}; + +export default LivestreamSettings; diff --git a/apps/web/src/components/Composer/Actions/PollSettings/PollEditor.tsx b/apps/web/src/components/Composer/Actions/PollSettings/PollEditor.tsx new file mode 100644 index 000000000000..c05e583f9ac4 --- /dev/null +++ b/apps/web/src/components/Composer/Actions/PollSettings/PollEditor.tsx @@ -0,0 +1,132 @@ +import { ClockIcon, PlusIcon, XMarkIcon } from "@heroicons/react/24/outline"; +import { Bars3BottomLeftIcon, XCircleIcon } from "@heroicons/react/24/solid"; +import { Button, Card, Input, Modal, Tooltip } from "@hey/ui"; +import plur from "plur"; +import type { FC } from "react"; +import { useState } from "react"; +import { usePostPollStore } from "src/store/non-persisted/post/usePostPollStore"; + +const PollEditor: FC = () => { + const { pollConfig, resetPollConfig, setPollConfig, setShowPollEditor } = + usePostPollStore(); + const [showPollLengthModal, setShowPollLengthModal] = useState(false); + + return ( + +
+
+ + Poll +
+
+ + setShowPollLengthModal(false)} + show={showPollLengthModal} + title="Poll length" + > +
+ + setPollConfig({ + ...pollConfig, + length: Number(e.target.value) + }) + } + type="number" + value={pollConfig.length} + /> +
+ + +
+
+
+ + + +
+
+
+ {pollConfig.options.map((choice, index) => ( +
+ 1 ? ( + + ) : null + } + onChange={(event) => { + const newOptions = [...pollConfig.options]; + newOptions[index] = event.target.value; + setPollConfig({ ...pollConfig, options: newOptions }); + }} + placeholder={`Choice ${index + 1}`} + value={choice} + /> +
+ ))} + {pollConfig.options.length !== 10 ? ( + + ) : null} +
+
+ ); +}; + +export default PollEditor; diff --git a/apps/web/src/components/Composer/Actions/PollSettings/index.tsx b/apps/web/src/components/Composer/Actions/PollSettings/index.tsx new file mode 100644 index 000000000000..831011ee2f08 --- /dev/null +++ b/apps/web/src/components/Composer/Actions/PollSettings/index.tsx @@ -0,0 +1,27 @@ +import { Bars3BottomLeftIcon } from "@heroicons/react/24/solid"; +import { Tooltip } from "@hey/ui"; +import type { FC } from "react"; +import { usePostPollStore } from "src/store/non-persisted/post/usePostPollStore"; + +const PollSettings: FC = () => { + const { resetPollConfig, setShowPollEditor, showPollEditor } = + usePostPollStore(); + + return ( + + + + ); +}; + +export default PollSettings; diff --git a/apps/web/src/components/Composer/Actions/ReferenceSettings/index.tsx b/apps/web/src/components/Composer/Actions/ReferenceSettings/index.tsx index e626ce191934..951910d43dc6 100644 --- a/apps/web/src/components/Composer/Actions/ReferenceSettings/index.tsx +++ b/apps/web/src/components/Composer/Actions/ReferenceSettings/index.tsx @@ -1,133 +1,150 @@ -import { Menu, Transition } from '@headlessui/react'; -import { GlobeAltIcon, UserAddIcon, UserGroupIcon, UsersIcon } from '@heroicons/react/outline'; -import { CheckCircleIcon } from '@heroicons/react/solid'; -import { Leafwatch } from '@lib/leafwatch'; -import clsx from 'clsx'; -import { motion } from 'framer-motion'; -import { ReferenceModules } from 'lens'; -import type { FC, ReactNode } from 'react'; -import { Fragment } from 'react'; -import { useReferenceModuleStore } from 'src/store/reference-module'; -import { PUBLICATION } from 'src/tracking'; +import MenuTransition from "@components/Shared/MenuTransition"; +import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react"; +import { + GlobeAltIcon, + UserGroupIcon, + UserPlusIcon, + UsersIcon +} from "@heroicons/react/24/outline"; +import { CheckCircleIcon } from "@heroicons/react/24/solid"; +import { ReferenceModuleType } from "@hey/lens"; +import { Tooltip } from "@hey/ui"; +import cn from "@hey/ui/cn"; +import type { FC, ReactNode } from "react"; +import { useReferenceModuleStore } from "src/store/non-persisted/useReferenceModuleStore"; const ReferenceSettings: FC = () => { - const selectedReferenceModule = useReferenceModuleStore((state) => state.selectedReferenceModule); - const setSelectedReferenceModule = useReferenceModuleStore((state) => state.setSelectedReferenceModule); - const onlyFollowers = useReferenceModuleStore((state) => state.onlyFollowers); - const setOnlyFollowers = useReferenceModuleStore((state) => state.setOnlyFollowers); - const degreesOfSeparation = useReferenceModuleStore((state) => state.degreesOfSeparation); - const setDegreesOfSeparation = useReferenceModuleStore((state) => state.setDegreesOfSeparation); - const MY_FOLLOWS = 'My follows'; - const MY_FOLLOWERS = 'My followers'; - const FRIENDS_OF_FRIENDS = 'Friends of friends'; - const EVERYONE = 'Everyone'; + const { + degreesOfSeparation, + onlyFollowers, + selectedReferenceModule, + setDegreesOfSeparation, + setOnlyFollowers, + setSelectedReferenceModule + } = useReferenceModuleStore(); + + const MY_FOLLOWS = "My follows"; + const MY_FOLLOWERS = "My followers"; + const FRIENDS_OF_FRIENDS = "Friends of friends"; + const EVERYONE = "Everyone"; const isFollowerOnlyReferenceModule = - selectedReferenceModule === ReferenceModules.FollowerOnlyReferenceModule; + selectedReferenceModule === ReferenceModuleType.FollowerOnlyReferenceModule; const isDegreesOfSeparationReferenceModule = - selectedReferenceModule === ReferenceModules.DegreesOfSeparationReferenceModule; + selectedReferenceModule === + ReferenceModuleType.DegreesOfSeparationReferenceModule; const isEveryone = isFollowerOnlyReferenceModule && !onlyFollowers; const isMyFollowers = isFollowerOnlyReferenceModule && onlyFollowers; - const isMyFollows = isDegreesOfSeparationReferenceModule && degreesOfSeparation === 1; - const isFriendsOfFriends = isDegreesOfSeparationReferenceModule && degreesOfSeparation === 2; + const isMyFollows = + isDegreesOfSeparationReferenceModule && degreesOfSeparation === 1; + const isFriendsOfFriends = + isDegreesOfSeparationReferenceModule && degreesOfSeparation === 2; interface ModuleProps { - title: string; icon: ReactNode; onClick: () => void; selected: boolean; + title: string; } - const Module: FC = ({ title, icon, onClick, selected }) => ( - + const Module: FC = ({ icon, onClick, selected, title }) => ( +
-
{icon}
+ {icon}
{title}
- {selected && } + {selected ? : null}
-
+ ); + const getSelectedReferenceModuleTooltipText = () => { + if (isMyFollowers) { + return "My followers can comment and mirror"; + } + + if (isMyFollows) { + return "My follows can comment and mirror"; + } + + if (isFriendsOfFriends) { + return "Friend of friends can comment and mirror"; + } + + return "Everyone can comment and mirror"; + }; + return ( - - {({ open }) => ( - <> - { - Leafwatch.track(PUBLICATION.NEW.REFERENCE_MODULE.OPEN_REFERENCE_SETTINGS); - }} - > -
- {isEveryone && } - {isMyFollowers && } - {isMyFollows && } - {isFriendsOfFriends && } -
-
- + + + {isEveryone ? : null} + {isMyFollowers ? : null} + {isMyFollows ? : null} + {isFriendsOfFriends ? : null} + + + - - } - onClick={() => { - setSelectedReferenceModule(ReferenceModules.FollowerOnlyReferenceModule); - setOnlyFollowers(false); - Leafwatch.track(PUBLICATION.NEW.REFERENCE_MODULE.EVERYONE); - }} - /> - } - onClick={() => { - setSelectedReferenceModule(ReferenceModules.FollowerOnlyReferenceModule); - setOnlyFollowers(true); - Leafwatch.track(PUBLICATION.NEW.REFERENCE_MODULE.MY_FOLLOWERS); - }} - /> - } - onClick={() => { - setSelectedReferenceModule(ReferenceModules.DegreesOfSeparationReferenceModule); - setDegreesOfSeparation(1); - Leafwatch.track(PUBLICATION.NEW.REFERENCE_MODULE.MY_FOLLOWS); - }} - /> - } - onClick={() => { - setSelectedReferenceModule(ReferenceModules.DegreesOfSeparationReferenceModule); - setDegreesOfSeparation(2); - Leafwatch.track(PUBLICATION.NEW.REFERENCE_MODULE.FRIENDS_OF_FRIENDS); - }} - /> - - - - )} - + } + onClick={() => { + setSelectedReferenceModule( + ReferenceModuleType.FollowerOnlyReferenceModule + ); + setOnlyFollowers(false); + }} + selected={isEveryone} + title={EVERYONE} + /> + } + onClick={() => { + setSelectedReferenceModule( + ReferenceModuleType.FollowerOnlyReferenceModule + ); + setOnlyFollowers(true); + }} + selected={isMyFollowers} + title={MY_FOLLOWERS} + /> + } + onClick={() => { + setSelectedReferenceModule( + ReferenceModuleType.DegreesOfSeparationReferenceModule + ); + setDegreesOfSeparation(1); + }} + selected={isMyFollows} + title={MY_FOLLOWS} + /> + } + onClick={() => { + setSelectedReferenceModule( + ReferenceModuleType.DegreesOfSeparationReferenceModule + ); + setDegreesOfSeparation(2); + }} + selected={isFriendsOfFriends} + title={FRIENDS_OF_FRIENDS} + /> + + +
+ ); }; diff --git a/apps/web/src/components/Composer/ChooseThumbnail.tsx b/apps/web/src/components/Composer/ChooseThumbnail.tsx new file mode 100644 index 000000000000..1b983dc5c7d3 --- /dev/null +++ b/apps/web/src/components/Composer/ChooseThumbnail.tsx @@ -0,0 +1,188 @@ +import ThumbnailsShimmer from "@components/Shared/Shimmer/ThumbnailsShimmer"; +import { uploadFileToIPFS } from "@helpers/uploadToIPFS"; +import { CheckCircleIcon, PhotoIcon } from "@heroicons/react/24/outline"; +import { generateVideoThumbnails } from "@hey/helpers/generateVideoThumbnails"; +import getFileFromDataURL from "@hey/helpers/getFileFromDataURL"; +import { Spinner } from "@hey/ui"; +import type { ChangeEvent, FC } from "react"; +import { useEffect, useState } from "react"; +import { toast } from "react-hot-toast"; +import { usePostAttachmentStore } from "src/store/non-persisted/post/usePostAttachmentStore"; +import { usePostVideoStore } from "src/store/non-persisted/post/usePostVideoStore"; + +const DEFAULT_THUMBNAIL_INDEX = 0; +export const THUMBNAIL_GENERATE_COUNT = 4; + +interface Thumbnail { + blobUrl: string; + ipfsUrl: string; +} + +const ChooseThumbnail: FC = () => { + const [thumbnails, setThumbnails] = useState([]); + const [imageUploading, setImageUploading] = useState(false); + const [selectedThumbnailIndex, setSelectedThumbnailIndex] = useState(-1); + const { attachments } = usePostAttachmentStore((state) => state); + const { setVideoThumbnail, videoThumbnail } = usePostVideoStore(); + const { file } = attachments[0]; + + const uploadThumbnailToIpfs = async (fileToUpload: File) => { + setVideoThumbnail({ ...videoThumbnail, uploading: true }); + const result = await uploadFileToIPFS(fileToUpload); + if (!result.uri) { + toast.error("Failed to upload thumbnail"); + } + setVideoThumbnail({ + mimeType: fileToUpload.type || "image/jpeg", + uploading: false, + url: result.uri + }); + + return result; + }; + + const handleSelectThumbnail = (index: number) => { + setSelectedThumbnailIndex(index); + if (thumbnails[index]?.ipfsUrl === "") { + setVideoThumbnail({ ...videoThumbnail, uploading: true }); + getFileFromDataURL( + thumbnails[index].blobUrl, + "thumbnail.jpeg", + async (file: any) => { + if (!file) { + return toast.error("Please upload a custom thumbnail"); + } + const ipfsResult = await uploadThumbnailToIpfs(file); + setThumbnails( + thumbnails.map((thumbnail, i) => { + if (i === index) { + thumbnail.ipfsUrl = ipfsResult.uri; + } + return thumbnail; + }) + ); + } + ); + } else { + setVideoThumbnail({ + ...videoThumbnail, + uploading: false, + url: thumbnails[index]?.ipfsUrl + }); + } + }; + + const generateThumbnails = async (fileToGenerate: File) => { + try { + const thumbnailArray = await generateVideoThumbnails( + fileToGenerate, + THUMBNAIL_GENERATE_COUNT + ); + const thumbnailList: Thumbnail[] = []; + for (const thumbnailBlob of thumbnailArray) { + thumbnailList.push({ blobUrl: thumbnailBlob, ipfsUrl: "" }); + } + setThumbnails(thumbnailList); + setSelectedThumbnailIndex(DEFAULT_THUMBNAIL_INDEX); + } catch {} + }; + + useEffect(() => { + handleSelectThumbnail(selectedThumbnailIndex); + }, [selectedThumbnailIndex]); + + useEffect(() => { + if (file) { + generateThumbnails(file); + } + return () => { + setSelectedThumbnailIndex(-1); + setThumbnails([]); + }; + }, [file]); + + const handleUpload = async (e: ChangeEvent) => { + if (e.target.files?.length) { + try { + setImageUploading(true); + setSelectedThumbnailIndex(-1); + const file = e.target.files[0]; + const result = await uploadThumbnailToIpfs(file); + const preview = window.URL?.createObjectURL(file); + setThumbnails([ + { blobUrl: preview, ipfsUrl: result.uri }, + ...thumbnails + ]); + setSelectedThumbnailIndex(0); + } catch { + toast.error("Failed to upload thumbnail"); + } finally { + setImageUploading(false); + } + } + }; + + const isUploading = videoThumbnail.uploading; + + return ( +
+ Choose Thumbnail +
+ + {thumbnails.length ? null : } + {thumbnails.map(({ blobUrl, ipfsUrl }, index) => { + const isSelected = selectedThumbnailIndex === index; + const isUploaded = ipfsUrl === videoThumbnail.url; + + return ( + + ); + })} +
+
+ ); +}; + +export default ChooseThumbnail; diff --git a/apps/web/src/components/Composer/Comment/New.tsx b/apps/web/src/components/Composer/Comment/New.tsx deleted file mode 100644 index df70889f25e5..000000000000 --- a/apps/web/src/components/Composer/Comment/New.tsx +++ /dev/null @@ -1,400 +0,0 @@ -import Attachments from '@components/Shared/Attachments'; -import { AudioPublicationSchema } from '@components/Shared/Audio'; -import withLexicalContext from '@components/Shared/Lexical/withLexicalContext'; -import { Button } from '@components/UI/Button'; -import { Card } from '@components/UI/Card'; -import { ErrorMessage } from '@components/UI/ErrorMessage'; -import { Spinner } from '@components/UI/Spinner'; -import useBroadcast from '@components/utils/hooks/useBroadcast'; -import type { LensterAttachment, LensterPublication } from '@generated/types'; -import type { IGif } from '@giphy/js-types'; -import { ChatAlt2Icon } from '@heroicons/react/outline'; -import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; -import getSignature from '@lib/getSignature'; -import getTags from '@lib/getTags'; -import getTextNftUrl from '@lib/getTextNftUrl'; -import getUserLocale from '@lib/getUserLocale'; -import { Leafwatch } from '@lib/leafwatch'; -import onError from '@lib/onError'; -import splitSignature from '@lib/splitSignature'; -import trimify from '@lib/trimify'; -import uploadToArweave from '@lib/uploadToArweave'; -import { LensHubProxy } from 'abis'; -import { - ALLOWED_AUDIO_TYPES, - ALLOWED_IMAGE_TYPES, - ALLOWED_VIDEO_TYPES, - APP_NAME, - LENSHUB_PROXY, - RELAY_ON, - SIGN_WALLET -} from 'data/constants'; -import type { CreatePublicCommentRequest } from 'lens'; -import { - PublicationMainFocus, - ReferenceModules, - useCreateCommentTypedDataMutation, - useCreateCommentViaDispatcherMutation -} from 'lens'; -import { $getRoot } from 'lexical'; -import dynamic from 'next/dynamic'; -import type { FC } from 'react'; -import { useEffect, useState } from 'react'; -import toast from 'react-hot-toast'; -import { useAppStore } from 'src/store/app'; -import { useCollectModuleStore } from 'src/store/collect-module'; -import { usePublicationStore } from 'src/store/publication'; -import { useReferenceModuleStore } from 'src/store/reference-module'; -import { useTransactionPersistStore } from 'src/store/transaction'; -import { COMMENT } from 'src/tracking'; -import { v4 as uuid } from 'uuid'; -import { useContractWrite, useSignTypedData } from 'wagmi'; - -import Editor from '../Editor'; - -const Attachment = dynamic(() => import('@components/Composer/Actions/Attachment'), { - loading: () =>
-}); -const Giphy = dynamic(() => import('@components/Composer/Actions/Giphy'), { - loading: () =>
-}); -const CollectSettings = dynamic(() => import('@components/Composer/Actions/CollectSettings'), { - loading: () =>
-}); -const ReferenceSettings = dynamic(() => import('@components/Composer/Actions/ReferenceSettings'), { - loading: () =>
-}); -const AccessSettings = dynamic(() => import('@components/Composer/Actions/AccessSettings'), { - loading: () =>
-}); - -interface Props { - publication: LensterPublication; -} - -const NewComment: FC = ({ publication }) => { - // App store - const userSigNonce = useAppStore((state) => state.userSigNonce); - const setUserSigNonce = useAppStore((state) => state.setUserSigNonce); - const currentProfile = useAppStore((state) => state.currentProfile); - - // Publication store - const publicationContent = usePublicationStore((state) => state.publicationContent); - const setPublicationContent = usePublicationStore((state) => state.setPublicationContent); - const audioPublication = usePublicationStore((state) => state.audioPublication); - - // Transaction persist store - const txnQueue = useTransactionPersistStore((state) => state.txnQueue); - const setTxnQueue = useTransactionPersistStore((state) => state.setTxnQueue); - - // Collect module store - const payload = useCollectModuleStore((state) => state.payload); - const resetCollectSettings = useCollectModuleStore((state) => state.reset); - - // Reference module store - const selectedReferenceModule = useReferenceModuleStore((state) => state.selectedReferenceModule); - const onlyFollowers = useReferenceModuleStore((state) => state.onlyFollowers); - const degreesOfSeparation = useReferenceModuleStore((state) => state.degreesOfSeparation); - - // States - const [commentContentError, setCommentContentError] = useState(''); - const [isUploading, setIsUploading] = useState(false); - const [attachments, setAttachments] = useState([]); - const [editor] = useLexicalComposerContext(); - - const isAudioComment = ALLOWED_AUDIO_TYPES.includes(attachments[0]?.type); - - const onCompleted = () => { - editor.update(() => { - $getRoot().clear(); - }); - setPublicationContent(''); - setAttachments([]); - resetCollectSettings(); - Leafwatch.track(COMMENT.NEW); - }; - - useEffect(() => { - setCommentContentError(''); - }, [audioPublication]); - - const generateOptimisticComment = ({ txHash, txId }: { txHash?: string; txId?: string }) => { - return { - id: uuid(), - parent: publication.id, - type: 'NEW_COMMENT', - txHash, - txId, - content: publicationContent, - attachments, - title: audioPublication.title, - cover: audioPublication.cover, - author: audioPublication.author - }; - }; - - const { isLoading: signLoading, signTypedDataAsync } = useSignTypedData({ onError }); - - const { - error, - isLoading: writeLoading, - write - } = useContractWrite({ - address: LENSHUB_PROXY, - abi: LensHubProxy, - functionName: 'commentWithSig', - mode: 'recklesslyUnprepared', - onSuccess: ({ hash }) => { - onCompleted(); - setTxnQueue([generateOptimisticComment({ txHash: hash }), ...txnQueue]); - }, - onError - }); - - const { broadcast, loading: broadcastLoading } = useBroadcast({ - onCompleted: (data) => { - onCompleted(); - setTxnQueue([generateOptimisticComment({ txId: data?.broadcast?.txId }), ...txnQueue]); - } - }); - const [createCommentTypedData, { loading: typedDataLoading }] = useCreateCommentTypedDataMutation({ - onCompleted: async ({ createCommentTypedData }) => { - try { - const { id, typedData } = createCommentTypedData; - const { - profileId, - profileIdPointed, - pubIdPointed, - contentURI, - collectModule, - collectModuleInitData, - referenceModule, - referenceModuleData, - referenceModuleInitData, - deadline - } = typedData.value; - const signature = await signTypedDataAsync(getSignature(typedData)); - const { v, r, s } = splitSignature(signature); - const sig = { v, r, s, deadline }; - const inputStruct = { - profileId, - profileIdPointed, - pubIdPointed, - contentURI, - collectModule, - collectModuleInitData, - referenceModule, - referenceModuleData, - referenceModuleInitData, - sig - }; - - setUserSigNonce(userSigNonce + 1); - if (!RELAY_ON) { - return write?.({ recklesslySetUnpreparedArgs: [inputStruct] }); - } - - const { - data: { broadcast: result } - } = await broadcast({ request: { id, signature } }); - - if ('reason' in result) { - write?.({ recklesslySetUnpreparedArgs: [inputStruct] }); - } - } catch {} - }, - onError - }); - - const [createCommentViaDispatcher, { loading: dispatcherLoading }] = useCreateCommentViaDispatcherMutation({ - onCompleted: (data) => { - onCompleted(); - if (data.createCommentViaDispatcher.__typename === 'RelayerResult') { - setTxnQueue([generateOptimisticComment({ txId: data.createCommentViaDispatcher.txId }), ...txnQueue]); - } - }, - onError - }); - - const createViaDispatcher = async (request: CreatePublicCommentRequest) => { - const { data } = await createCommentViaDispatcher({ - variables: { request } - }); - if (data?.createCommentViaDispatcher?.__typename === 'RelayError') { - createCommentTypedData({ - variables: { - options: { overrideSigNonce: userSigNonce }, - request - } - }); - } - }; - - const getMainContentFocus = () => { - if (attachments.length > 0) { - if (isAudioComment) { - return PublicationMainFocus.Audio; - } else if (ALLOWED_IMAGE_TYPES.includes(attachments[0]?.type)) { - return PublicationMainFocus.Image; - } else if (ALLOWED_VIDEO_TYPES.includes(attachments[0]?.type)) { - return PublicationMainFocus.Video; - } - } else { - return PublicationMainFocus.TextOnly; - } - }; - - const getAnimationUrl = () => { - if (attachments.length > 0 && (isAudioComment || ALLOWED_VIDEO_TYPES.includes(attachments[0]?.type))) { - return attachments[0]?.item; - } - return null; - }; - - const getAttachmentImage = () => { - return isAudioComment ? audioPublication.cover : attachments[0]?.item; - }; - - const getAttachmentImageMimeType = () => { - return isAudioComment ? audioPublication.coverMimeType : attachments[0]?.type; - }; - - const createComment = async () => { - if (!currentProfile) { - return toast.error(SIGN_WALLET); - } - - if (isAudioComment) { - setCommentContentError(''); - const parsedData = AudioPublicationSchema.safeParse(audioPublication); - if (!parsedData.success) { - const issue = parsedData.error.issues[0]; - return setCommentContentError(issue.message); - } - } - - if (publicationContent.length === 0 && attachments.length === 0) { - return setCommentContentError('Comment should not be empty!'); - } - - setCommentContentError(''); - setIsUploading(true); - - let textNftImageUrl = null; - if (!attachments.length) { - textNftImageUrl = await getTextNftUrl( - publicationContent, - currentProfile.handle, - new Date().toLocaleString() - ); - } - - const attributes = [ - { - traitType: 'type', - displayType: 'string', - value: getMainContentFocus()?.toLowerCase() - } - ]; - if (isAudioComment) { - attributes.push({ - traitType: 'author', - displayType: 'string', - value: audioPublication.author - }); - } - - const id = await uploadToArweave({ - version: '2.0.0', - metadata_id: uuid(), - description: trimify(publicationContent), - content: trimify(publicationContent), - external_url: `https://lenster.xyz/u/${currentProfile?.handle}`, - image: attachments.length > 0 ? getAttachmentImage() : textNftImageUrl, - imageMimeType: attachments.length > 0 ? getAttachmentImageMimeType() : 'image/svg+xml', - name: isAudioComment ? audioPublication.title : `Comment by @${currentProfile?.handle}`, - tags: getTags(publicationContent), - animation_url: getAnimationUrl(), - mainContentFocus: getMainContentFocus(), - contentWarning: null, - attributes, - media: attachments, - locale: getUserLocale(), - createdOn: new Date(), - appId: APP_NAME - }).finally(() => setIsUploading(false)); - - const request = { - profileId: currentProfile?.id, - publicationId: publication.__typename === 'Mirror' ? publication?.mirrorOf?.id : publication?.id, - contentURI: `https://arweave.net/${id}`, - collectModule: payload, - referenceModule: - selectedReferenceModule === ReferenceModules.FollowerOnlyReferenceModule - ? { followerOnlyReferenceModule: onlyFollowers ? true : false } - : { - degreesOfSeparationReferenceModule: { - commentsRestricted: true, - mirrorsRestricted: true, - degreesOfSeparation - } - } - }; - - if (currentProfile?.dispatcher?.canUseRelay) { - createViaDispatcher(request); - } else { - createCommentTypedData({ - variables: { - options: { overrideSigNonce: userSigNonce }, - request - } - }); - } - }; - - const setGifAttachment = (gif: IGif) => { - const attachment = { - item: gif.images.original.url, - type: 'image/gif', - altTag: gif.title - }; - setAttachments([...attachments, attachment]); - }; - - const isLoading = - isUploading || typedDataLoading || dispatcherLoading || signLoading || writeLoading || broadcastLoading; - - return ( - - {error && } - - {commentContentError && ( -
{commentContentError}
- )} -
-
- - setGifAttachment(gif)} /> - - - -
-
- -
-
-
- -
-
- ); -}; - -export default withLexicalContext(NewComment); diff --git a/apps/web/src/components/Composer/Editor/ClubPicker.tsx b/apps/web/src/components/Composer/Editor/ClubPicker.tsx new file mode 100644 index 000000000000..f71c1dcbfc2c --- /dev/null +++ b/apps/web/src/components/Composer/Editor/ClubPicker.tsx @@ -0,0 +1,81 @@ +import type { EditorExtension } from "@helpers/prosekit/extension"; +import { EditorRegex } from "@hey/data/regex"; +import { Image } from "@hey/ui"; +import cn from "@hey/ui/cn"; +import { useEditor } from "prosekit/react"; +import { + AutocompleteItem, + AutocompleteList, + AutocompletePopover +} from "prosekit/react/autocomplete"; +import type { FC } from "react"; +import { useState } from "react"; +import type { ClubProfile } from "src/hooks/prosekit/useClubQuery"; +import useClubQuery from "src/hooks/prosekit/useClubQuery"; + +interface ClubItemProps { + club: ClubProfile; + onSelect: VoidFunction; +} + +const ClubItem: FC = ({ club, onSelect }) => { + return ( +
+ + {club.handle} +
+ {club.name} + {club.displayHandle} +
+
+
+ ); +}; + +const ClubPicker: FC = () => { + const editor = useEditor(); + const [queryString, setQueryString] = useState(""); + const results = useClubQuery(queryString); + + const handleClubInsert = (club: ClubProfile) => { + editor.commands.insertMention({ + id: club.id.toString(), + kind: "club", + value: club.displayHandle + }); + editor.commands.insertText({ text: " " }); + }; + + return ( + + + {results.map((club) => ( + handleClubInsert(club)} + /> + ))} + + + ); +}; + +export default ClubPicker; diff --git a/apps/web/src/components/Composer/Editor/Editor.tsx b/apps/web/src/components/Composer/Editor/Editor.tsx new file mode 100644 index 000000000000..50193919872c --- /dev/null +++ b/apps/web/src/components/Composer/Editor/Editor.tsx @@ -0,0 +1,61 @@ +import { defineEditorExtension } from "@helpers/prosekit/extension"; +import { htmlFromMarkdown } from "@helpers/prosekit/markdown"; +import getAvatar from "@hey/helpers/getAvatar"; +import { Image } from "@hey/ui"; +import dynamic from "next/dynamic"; +import "prosekit/basic/style.css"; +import { createEditor } from "prosekit/core"; +import { ProseKit } from "prosekit/react"; +import type { FC } from "react"; +import { useMemo, useRef } from "react"; +import useContentChange from "src/hooks/prosekit/useContentChange"; +import useFocus from "src/hooks/prosekit/useFocus"; +import { usePaste } from "src/hooks/prosekit/usePaste"; +import { usePostStore } from "src/store/non-persisted/post/usePostStore"; +import { useAccountStore } from "src/store/persisted/useAccountStore"; +import { useEditorHandle } from "./EditorHandle"; + +// Lazy load EditorMenus to reduce bundle size +const EditorMenus = dynamic(() => import("./EditorMenus"), { ssr: false }); + +const Editor: FC = () => { + const { currentAccount } = useAccountStore(); + const { postContent } = usePostStore(); + const defaultMarkdownRef = useRef(postContent); + + const defaultContent = useMemo(() => { + const markdown = defaultMarkdownRef.current; + return markdown ? htmlFromMarkdown(markdown) : undefined; + }, []); + + const editor = useMemo(() => { + const extension = defineEditorExtension(); + return createEditor({ defaultContent, extension }); + }, [defaultContent]); + + useFocus(editor); + useContentChange(editor); + usePaste(editor); + useEditorHandle(editor); + + return ( + +
+ {currentAccount?.address} +
+ +
+
+
+ + ); +}; + +export default Editor; diff --git a/apps/web/src/components/Composer/Editor/EditorHandle.tsx b/apps/web/src/components/Composer/Editor/EditorHandle.tsx new file mode 100644 index 000000000000..19bda37cc9c1 --- /dev/null +++ b/apps/web/src/components/Composer/Editor/EditorHandle.tsx @@ -0,0 +1,79 @@ +import type { EditorExtension } from "@helpers/prosekit/extension"; +import { setMarkdownContent } from "@helpers/prosekit/markdownContent"; +import type { Editor } from "prosekit/core"; +import type { FC, ReactNode } from "react"; +import { createContext, useContext, useEffect, useState } from "react"; + +interface EditorHandle { + insertText: (text: string) => void; + setMarkdown: (markdown: string) => void; +} + +const HandleContext = createContext(null); +const SetHandleContext = createContext<((handle: EditorHandle) => void) | null>( + null +); + +interface EditorProps { + children: ReactNode; +} + +const Provider: FC = ({ children }) => { + const [handle, setHandle] = useState(null); + + return ( + + + {children} + + + ); +}; + +/** + * A hook for accessing the text editor handle. + */ +export const useEditorContext = (): EditorHandle | null => { + return useContext(HandleContext); +}; + +/** + * A hook to register the text editor handle. + */ +export const useEditorHandle = (editor: Editor) => { + const setHandle = useContext(SetHandleContext); + + useEffect(() => { + const handle: EditorHandle = { + insertText: (text: string): void => { + if (!editor.mounted) { + return; + } + + editor.commands.insertText({ text }); + }, + setMarkdown: (markdown: string): void => { + setMarkdownContent(editor, markdown); + } + }; + + setHandle?.(handle); + }, [setHandle, editor]); +}; + +/** + * A higher-order component for providing the text editor handle. + */ +export const withEditorContext = ( + Component: FC +): FC => { + const WithEditorContext: FC = (props: Props) => { + return ( + + + + ); + }; + + return WithEditorContext; +}; diff --git a/apps/web/src/components/Composer/Editor/EditorMenus.tsx b/apps/web/src/components/Composer/Editor/EditorMenus.tsx new file mode 100644 index 000000000000..a7109490a177 --- /dev/null +++ b/apps/web/src/components/Composer/Editor/EditorMenus.tsx @@ -0,0 +1,18 @@ +import type { FC } from "react"; +import ClubPicker from "./ClubPicker"; +import EmojiPicker from "./EmojiPicker"; +import InlineMenu from "./InlineMenu"; +import MentionPicker from "./MentionPicker"; + +const EditorMenus: FC = () => { + return ( + <> + + + + + + ); +}; + +export default EditorMenus; diff --git a/apps/web/src/components/Composer/Editor/EmojiPicker.tsx b/apps/web/src/components/Composer/Editor/EmojiPicker.tsx new file mode 100644 index 000000000000..d67014a32e7f --- /dev/null +++ b/apps/web/src/components/Composer/Editor/EmojiPicker.tsx @@ -0,0 +1,68 @@ +import type { EditorExtension } from "@helpers/prosekit/extension"; +import { EditorRegex } from "@hey/data/regex"; +import type { Emoji } from "@hey/types/misc"; +import cn from "@hey/ui/cn"; +import { useEditor } from "prosekit/react"; +import { + AutocompleteItem, + AutocompleteList, + AutocompletePopover +} from "prosekit/react/autocomplete"; +import type { FC } from "react"; +import { useState } from "react"; +import useEmojiQuery from "src/hooks/prosekit/useEmojiQuery"; + +interface EmojiItemProps { + emoji: Emoji; + onSelect: VoidFunction; +} + +const EmojiItem: FC = ({ emoji, onSelect }) => { + return ( + +
+ {emoji.emoji} + + {emoji.aliases[0].split("_").join(" ")} + +
+
+ ); +}; + +const EmojiPicker: FC = () => { + const editor = useEditor(); + const [query, setQuery] = useState(""); + const emojis = useEmojiQuery(query); + + const handleInsert = (emoji: Emoji) => { + editor.commands.insertText({ text: emoji.emoji }); + }; + + return ( + + + {emojis.map((emoji) => ( + handleInsert(emoji)} + /> + ))} + + + ); +}; + +export default EmojiPicker; diff --git a/apps/web/src/components/Composer/Editor/InlineMenu.tsx b/apps/web/src/components/Composer/Editor/InlineMenu.tsx new file mode 100644 index 000000000000..6439ba676abc --- /dev/null +++ b/apps/web/src/components/Composer/Editor/InlineMenu.tsx @@ -0,0 +1,33 @@ +import type { EditorExtension } from "@helpers/prosekit/extension"; +import { BoldIcon, ItalicIcon } from "@heroicons/react/24/outline"; +import { useEditor } from "prosekit/react"; +import { InlinePopover } from "prosekit/react/inline-popover"; +import type { FC } from "react"; +import Toggle from "./Toggle"; + +const InlineMenu: FC = () => { + const editor = useEditor({ update: true }); + + return ( + + editor.commands.toggleBold()} + pressed={editor.marks.bold.isActive()} + tooltip="Bold" + > + + + editor.commands.toggleItalic()} + pressed={editor.marks.italic.isActive()} + tooltip="Italic" + > + + + + ); +}; + +export default InlineMenu; diff --git a/apps/web/src/components/Composer/Editor/MentionPicker.tsx b/apps/web/src/components/Composer/Editor/MentionPicker.tsx new file mode 100644 index 000000000000..209911359291 --- /dev/null +++ b/apps/web/src/components/Composer/Editor/MentionPicker.tsx @@ -0,0 +1,87 @@ +import Misuse from "@components/Shared/Account/Icons/Misuse"; +import Verified from "@components/Shared/Account/Icons/Verified"; +import type { EditorExtension } from "@helpers/prosekit/extension"; +import { EditorRegex } from "@hey/data/regex"; +import { Image } from "@hey/ui"; +import cn from "@hey/ui/cn"; +import { useEditor } from "prosekit/react"; +import { + AutocompleteItem, + AutocompleteList, + AutocompletePopover +} from "prosekit/react/autocomplete"; +import type { FC } from "react"; +import { useState } from "react"; +import type { MentionProfile } from "src/hooks/prosekit/useMentionQuery"; +import useMentionQuery from "src/hooks/prosekit/useMentionQuery"; + +interface MentionItemProps { + onSelect: VoidFunction; + account: MentionProfile; +} + +const MentionItem: FC = ({ onSelect, account }) => { + return ( +
+ + {account.handle} +
+
+ {account.name} + + +
+ {account.displayHandle} +
+
+
+ ); +}; + +const MentionPicker: FC = () => { + const editor = useEditor(); + const [queryString, setQueryString] = useState(""); + const results = useMentionQuery(queryString); + + const handleProfileInsert = (profile: MentionProfile) => { + editor.commands.insertMention({ + id: profile.id.toString(), + kind: "profile", + value: profile.handle + }); + editor.commands.insertText({ text: " " }); + }; + + return ( + + + {results.map((profile) => ( + handleProfileInsert(profile)} + account={profile} + /> + ))} + + + ); +}; + +export default MentionPicker; diff --git a/apps/web/src/components/Composer/Editor/Toggle.tsx b/apps/web/src/components/Composer/Editor/Toggle.tsx new file mode 100644 index 000000000000..08bc738b7c9e --- /dev/null +++ b/apps/web/src/components/Composer/Editor/Toggle.tsx @@ -0,0 +1,35 @@ +import { Tooltip } from "@hey/ui"; +import type { FC, ReactNode } from "react"; + +interface ToggleProps { + children: ReactNode; + disabled?: boolean; + onClick?: VoidFunction; + pressed: boolean; + tooltip?: string; +} + +const Toggle: FC = ({ + children, + disabled = false, + onClick, + pressed, + tooltip +}) => { + return ( + + + + ); +}; + +export default Toggle; diff --git a/apps/web/src/components/Composer/Editor/index.tsx b/apps/web/src/components/Composer/Editor/index.tsx index a7cab0e13365..11aa11f1ca2a 100644 --- a/apps/web/src/components/Composer/Editor/index.tsx +++ b/apps/web/src/components/Composer/Editor/index.tsx @@ -1,50 +1,3 @@ -import LexicalAutoLinkPlugin from '@components/Shared/Lexical/Plugins/AutoLinkPlugin'; -import ToolbarPlugin from '@components/Shared/Lexical/Plugins/ToolbarPlugin'; -import { $convertToMarkdownString, TEXT_FORMAT_TRANSFORMERS } from '@lexical/markdown'; -import { ContentEditable } from '@lexical/react/LexicalContentEditable'; -import { HashtagPlugin } from '@lexical/react/LexicalHashtagPlugin'; -import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'; -import { MarkdownShortcutPlugin } from '@lexical/react/LexicalMarkdownShortcutPlugin'; -import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'; -import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; -import { ERROR_MESSAGE } from 'data/constants'; -import type { FC } from 'react'; -import { usePublicationStore } from 'src/store/publication'; - -import MentionsPlugin from '../../Shared/Lexical/Plugins/AtMentionsPlugin'; - -const TRANSFORMERS = [...TEXT_FORMAT_TRANSFORMERS]; - -const Editor: FC = () => { - const setPublicationContent = usePublicationStore((state) => state.setPublicationContent); - - return ( -
- - } - placeholder={ -
- What's happening? -
- } - ErrorBoundary={() =>
{ERROR_MESSAGE}
} - /> - { - editorState.read(() => { - const markdown = $convertToMarkdownString(TRANSFORMERS); - setPublicationContent(markdown); - }); - }} - /> - - - - - -
- ); -}; - -export default Editor; +import Editor from "./Editor"; +export { Editor }; +export { useEditorContext, withEditorContext } from "./EditorHandle"; diff --git a/apps/web/src/components/Composer/LicensePicker.tsx b/apps/web/src/components/Composer/LicensePicker.tsx new file mode 100644 index 000000000000..94bbb451716a --- /dev/null +++ b/apps/web/src/components/Composer/LicensePicker.tsx @@ -0,0 +1,66 @@ +import getAssetLicense from "@helpers/getAssetLicense"; +import { Select, Tooltip } from "@hey/ui"; +import { MetadataLicenseType } from "@lens-protocol/metadata"; +import Link from "next/link"; +import type { FC } from "react"; +import { usePostLicenseStore } from "src/store/non-persisted/post/usePostLicenseStore"; + +const LicensePicker: FC = () => { + const { license, setLicense } = usePostLicenseStore(); + + const otherOptions = Object.values(MetadataLicenseType) + .filter((type) => getAssetLicense(type)) + .map((type) => ({ + label: getAssetLicense(type)?.label as string, + selected: license === type, + value: type + })) as any; + + const options = [ + { + label: "All rights reserved", + selected: license === null, + value: null + }, + ...otherOptions + ]; + + return ( +
+ {/*
*/} +
+ License + + Creator licenses dictate the use, sharing, and distribution of + music, art and other intellectual property - ranging from + restrictive to permissive. Once given, you can't change the + license. +
+ } + placement="top" + > +
What's this?
+ +
+ + } + /> + } + /> + + + +
+ + ); +}; + +export default InputDesign; diff --git a/apps/web/src/components/Design/ModalDesign.tsx b/apps/web/src/components/Design/ModalDesign.tsx new file mode 100644 index 000000000000..1605220a08cf --- /dev/null +++ b/apps/web/src/components/Design/ModalDesign.tsx @@ -0,0 +1,67 @@ +import { Button, Card, CardHeader, Modal } from "@hey/ui"; +import { type FC, useState } from "react"; + +const ModalDesign: FC = () => { + const [showXsModal, setShowXsModal] = useState(false); + const [showSmModal, setShowSmModal] = useState(false); + const [showMdModal, setShowMdModal] = useState(false); + const [showLgModal, setShowLgModal] = useState(false); + + const children = ( +
The quick brown fox jumps over the lazy dog.
+ ); + + return ( + + + setShowXsModal(false)} + show={showXsModal} + size="xs" + title="Extra small modal" + > + {children} + + setShowSmModal(false)} + show={showSmModal} + size="sm" + title="Small modal" + > + {children} + + setShowMdModal(false)} + show={showMdModal} + size="md" + title="Medium modal" + > + {children} + + setShowLgModal(false)} + show={showLgModal} + size="lg" + title="Large modal" + > + {children} + +
+ + + + +
+
+ ); +}; + +export default ModalDesign; diff --git a/apps/web/src/components/Design/NumberedStatDesign.tsx b/apps/web/src/components/Design/NumberedStatDesign.tsx new file mode 100644 index 000000000000..604f3189e451 --- /dev/null +++ b/apps/web/src/components/Design/NumberedStatDesign.tsx @@ -0,0 +1,16 @@ +import { Card, CardHeader, NumberedStat } from "@hey/ui"; +import type { FC } from "react"; + +const NumberedStatDesign: FC = () => { + return ( + + +
+ + +
+
+ ); +}; + +export default NumberedStatDesign; diff --git a/apps/web/src/components/Design/RangeSliderDesign.tsx b/apps/web/src/components/Design/RangeSliderDesign.tsx new file mode 100644 index 000000000000..1624320b40d6 --- /dev/null +++ b/apps/web/src/components/Design/RangeSliderDesign.tsx @@ -0,0 +1,40 @@ +import { Card, CardHeader, RangeSlider } from "@hey/ui"; +import { type FC, useState } from "react"; + +const RangeSliderDesign: FC = () => { + const [value, setValue] = useState(0); + + return ( + + +
+
+
Simple Range Slider
+ setValue(value[0])} + /> +
+
+
Range Slider with value in thumb
+ setValue(value[0])} + showValueInThumb + /> +
+
+
Range Slider with max value
+ setValue(value[0])} + max={1000} + showValueInThumb + /> +
+
+
+ ); +}; + +export default RangeSliderDesign; diff --git a/apps/web/src/components/Design/SelectDesign.tsx b/apps/web/src/components/Design/SelectDesign.tsx new file mode 100644 index 000000000000..7f79ae0b2ab2 --- /dev/null +++ b/apps/web/src/components/Design/SelectDesign.tsx @@ -0,0 +1,64 @@ +import { Card, CardHeader, Select } from "@hey/ui"; +import type { FC } from "react"; + +const SelectDesign: FC = () => { + const options = [ + { + htmlLabel: "Simple Label", + label: "Simple Label", + value: "simple-label", + selected: true + }, + { htmlLabel: HTML Label, label: "HTML Label", value: "html-label" } + ]; + + const optionsWithIcon = [ + { + label: "Option 1", + value: "1", + icon: "https://hey-assets.b-cdn.net/images/app-icon/0.png", + selected: true + }, + { + label: "Option 2", + value: "2", + icon: "https://hey-assets.b-cdn.net/images/app-icon/2.png" + }, + { + label: "Option 3", + value: "3", + icon: "https://hey-assets.b-cdn.net/images/app-icon/3.png" + }, + { + label: "Option 4", + value: "4", + icon: "https://hey-assets.b-cdn.net/images/app-icon/4.png" + } + ]; + + return ( + + +
+
+
Simple Select
+ {}} options={options} showSearch /> +
+
+
Select with Icon
+