diff --git a/.dockerignore b/.dockerignore index 3fd8f84..2e5fee2 100644 --- a/.dockerignore +++ b/.dockerignore @@ -34,6 +34,7 @@ yarn-error.log\* # local env files .env\*.local +.env.keys # vercel diff --git a/.env.development b/.env.development index 5681a5a..ef14f54 100644 --- a/.env.development +++ b/.env.development @@ -1,6 +1,11 @@ NEXT_TELEMETRY_DISABLED=1 CHAINHOOKS_API_TOKEN=dev-api-token +CRON_API_TOKEN=dev-api-token DATABASE_PATH=./sqlite.db +TWITTER_API_KEY=dev +TWITTER_API_SECRET_KEY=dev +TWITTER_ACCESS_TOKEN=dev +TWITTER_ACCESS_TOKEN_SECRET=dev # Client environment variables NEXT_PUBLIC_BASE_URL=http://localhost:3000 diff --git a/.env.production.build b/.env.production.build index d8d9ace..39e75a1 100644 --- a/.env.production.build +++ b/.env.production.build @@ -1,4 +1,9 @@ # file used by the CI to build the app NEXT_TELEMETRY_DISABLED=1 CHAINHOOKS_API_TOKEN=dev-api-token +CRON_API_TOKEN=dev-api-token DATABASE_PATH=./sqlite.db +TWITTER_API_KEY=dev +TWITTER_API_SECRET_KEY=dev +TWITTER_ACCESS_TOKEN=dev +TWITTER_ACCESS_TOKEN_SECRET=dev diff --git a/.env.vault b/.env.vault new file mode 100644 index 0000000..6b75374 --- /dev/null +++ b/.env.vault @@ -0,0 +1,8 @@ +#/-------------------.env.vault---------------------/ +#/ cloud-agnostic vaulting standard / +#/ [how it works](https://dotenvx.com/env-vault) / +#/--------------------------------------------------/ + +# production +DOTENV_VAULT_PRODUCTION="uRhaI/UpsVXjvkxWovxFhOcnZ2hhe5x6QbutGIVTs8RZZzOKTIccnRYv9q4fv2ZkQYPsQzsQS8RH8xrnsgxvv2g2PEsf3xXV93cSDDooo16gHCyYPEM0OSBQu9bakSm71V9+AfMv96Kha2hq/ThvYbZFfWvh9dZXROiZnIvjbkooyXD+yxIDbD/QVEQD8HwCm/o8LBfGsYF5NeQqs3guu3gFhxpyjhIt40zTxk2y/Qp4zoPPiqbTuVUO4DEAioXCVoRTb3IKz7lfW7zQnUvwFJkZ9bNLw2z52DcaSYJfxmwlkfgAMx2+M2ii8LK445zKCvOJ2zO6w50W+TWY1rOiyQbUb3FVqUUy+LbGWxQInTOUX4cbq2REUxEHz37iHgAAGKdwl8wbYBIzM+fVsZ9kmkKg0vneGw+vdQ7SFWNuZmpDnaG3gDOMbPmX+xNCtJvnNum6Ar24I2oyQonHJLTyO308rNpQrfIEV1JCJkrAKMl7iM/fRxCKxmVSCog+nOG66r0pGftm5JQ8fECjspzPxnihosKmvLqfrPAhboVd" + diff --git a/.github/workflows/cron-weekly.yml b/.github/workflows/cron-weekly.yml new file mode 100644 index 0000000..4084a85 --- /dev/null +++ b/.github/workflows/cron-weekly.yml @@ -0,0 +1,14 @@ +name: cron-weekly +on: + workflow_dispatch: + schedule: + - cron: '0 16 * * 6' +jobs: + cron: + runs-on: ubuntu-latest + steps: + - name: hourly-cron-job + run: | + curl --request GET \ + --url 'https://stackspulse.com/api/cron/weekly-users' \ + --header 'Authorization: Bearer ${{ secrets.CRON_API_TOKEN }}' diff --git a/.gitignore b/.gitignore index 49ef7cc..2c96297 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,7 @@ yarn-error.log* # local env files .env*.local +.env.keys # vercel .vercel diff --git a/.vscode/settings.json b/.vscode/settings.json index 6d9f2d6..5acbf25 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,8 +1,12 @@ { + "editor.defaultFormatter": "biomejs.biome", "editor.codeActionsOnSave": { "source.organizeImports.biome": "explicit", "quickfix.biome": "explicit" }, + "[typescriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, "[typescript]": { "editor.defaultFormatter": "biomejs.biome" } diff --git a/Dockerfile b/Dockerfile index 86efb4f..d4afe0a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -54,6 +54,7 @@ COPY --from=build /app/package.json ./package.json COPY --from=build /app/scripts ./scripts COPY --from=build /app/drizzle.config.ts ./drizzle.config.ts COPY --from=build /app/drizzle ./drizzle +COPY --from=build /app/.env.vault ./.env.vault COPY --from=build /app/public ./public COPY --from=build /app/.next/standalone ./ @@ -66,4 +67,4 @@ ENV DATABASE_PATH="/data/sqlite.db" # Start the server by default, this can be overwritten at runtime EXPOSE 3000 -CMD pnpm db:migrate && node server.js +CMD pnpm db:migrate && pnpm dotenvx run -- node server.js diff --git a/README.md b/README.md index b91cce5..cb81655 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,14 @@ fly deploy --remote-only Finally upload the chainhooks predicates file `chainhooks.production.json` to the [Hiro platform](https://platform.hiro.so/) for your project. +### Add new production environment variables + +Add the variable to the `.env.production.local` file and encrypt it using the `dotenvx` command: + +```bash +pnpm dotenvx encrypt --env-file .env.production.local +``` + ### Download the production database locally ```bash diff --git a/package.json b/package.json index 8cbc3c8..864cb2c 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ }, "dependencies": { "@dotenvx/dotenvx": "0.26.0", - "@radix-ui/themes": "2.0.3", + "@radix-ui/themes": "3.0.0", "@stacks/transactions": "6.13.0", "@t3-oss/env-core": "0.9.2", "@t3-oss/env-nextjs": "0.9.2", @@ -37,6 +37,7 @@ "sharp": "0.33.3", "tailwind-merge": "2.2.2", "tailwindcss-animate": "1.0.7", + "twitter-api-v2": "1.16.1", "zod": "3.22.4" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57714e5..0d86262 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ dependencies: specifier: 0.26.0 version: 0.26.0 '@radix-ui/themes': - specifier: 2.0.3 - version: 2.0.3(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + specifier: 3.0.0 + version: 3.0.0(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) '@stacks/transactions': specifier: 6.13.0 version: 6.13.0 @@ -65,6 +65,9 @@ dependencies: tailwindcss-animate: specifier: 1.0.7 version: 1.0.7(tailwindcss@3.4.1) + twitter-api-v2: + specifier: 1.16.1 + version: 1.16.1 zod: specifier: 3.22.4 version: 3.22.4 @@ -1525,7 +1528,7 @@ packages: '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.69)(react@18.2.0) '@types/react': 18.2.69 '@types/react-dom': 18.2.22 - aria-hidden: 1.2.3 + aria-hidden: 1.2.4 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) react-remove-scroll: 2.5.5(@types/react@18.2.69)(react@18.2.0) @@ -1757,12 +1760,46 @@ packages: '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.69)(react@18.2.0) '@types/react': 18.2.69 '@types/react-dom': 18.2.22 - aria-hidden: 1.2.3 + aria-hidden: 1.2.4 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) react-remove-scroll: 2.5.5(@types/react@18.2.69)(react@18.2.0) dev: false + /@radix-ui/react-navigation-menu@1.1.4(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-Cc+seCS3PmWmjI51ufGG7zp1cAAIRqHVw7C9LOA2TZ+R4hG6rDvHcTqIsEEFLmZO3zNVH72jOOE7kKNy8W+RtA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.24.1 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-collection': 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.69)(react@18.2.0) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.69)(react@18.2.0) + '@radix-ui/react-direction': 1.0.1(@types/react@18.2.69)(react@18.2.0) + '@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-id': 1.0.1(@types/react@18.2.69)(react@18.2.0) + '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.69)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.69)(react@18.2.0) + '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.2.69)(react@18.2.0) + '@radix-ui/react-use-previous': 1.0.1(@types/react@18.2.69)(react@18.2.0) + '@radix-ui/react-visually-hidden': 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@types/react': 18.2.69 + '@types/react-dom': 18.2.22 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-popover@1.0.7(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-shtvVnlsxT6faMnK/a7n0wptwBD23xc1Z5mdrtKLwVEfsEMXodS0r5s0/g5P0hX//EKYZS2sxUjqfzlg52ZSnQ==} peerDependencies: @@ -1792,7 +1829,7 @@ packages: '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.69)(react@18.2.0) '@types/react': 18.2.69 '@types/react-dom': 18.2.22 - aria-hidden: 1.2.3 + aria-hidden: 1.2.4 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) react-remove-scroll: 2.5.5(@types/react@18.2.69)(react@18.2.0) @@ -1892,6 +1929,28 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /@radix-ui/react-progress@1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-5G6Om/tYSxjSeEdrb1VfKkfZfn/1IlPWd731h2RfPuSbIfNUgfqAwbKfJCg/PP6nuUCTrYzalwHSpSinoWoCag==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.24.1 + '@radix-ui/react-context': 1.0.1(@types/react@18.2.69)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@types/react': 18.2.69 + '@types/react-dom': 18.2.22 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-radio-group@1.1.3(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-x+yELayyefNeKeTx4fjK6j99Fs6c4qKm3aY38G3swQVTN6xMpsrbigC0uHs2L//g8q4qR7qOcww8430jJmi2ag==} peerDependencies: @@ -2015,33 +2074,12 @@ packages: '@radix-ui/react-visually-hidden': 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) '@types/react': 18.2.69 '@types/react-dom': 18.2.22 - aria-hidden: 1.2.3 + aria-hidden: 1.2.4 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) react-remove-scroll: 2.5.5(@types/react@18.2.69)(react@18.2.0) dev: false - /@radix-ui/react-separator@1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-itYmTy/kokS21aiV5+Z56MZB54KrhPgn6eHDKkFeOLR34HMN2s8PaN47qZZAGnvupcjxHaFZnW4pQEh0BvvVuw==} - peerDependencies: - '@types/react': '*' - '@types/react-dom': '*' - react: ^16.8 || ^17.0 || ^18.0 - react-dom: ^16.8 || ^17.0 || ^18.0 - peerDependenciesMeta: - '@types/react': - optional: true - '@types/react-dom': - optional: true - dependencies: - '@babel/runtime': 7.24.1 - '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) - '@types/react': 18.2.69 - '@types/react-dom': 18.2.22 - react: 18.2.0 - react-dom: 18.2.0(react@18.2.0) - dev: false - /@radix-ui/react-slider@1.1.2(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-NKs15MJylfzVsCagVSWKhGGLNR1W9qWs+HtgbmjjVUB3B9+lb3PYoXxVju3kOrpf0VKyVCtZp+iTwVoqpa1Chw==} peerDependencies: @@ -2143,6 +2181,56 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /@radix-ui/react-toggle-group@1.0.4(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-Uaj/M/cMyiyT9Bx6fOZO0SAG4Cls0GptBWiBmBxofmDbNVnYYoyRWj/2M/6VCi/7qcXFWnHhRUfdfZFvvkuu8A==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.24.1 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-context': 1.0.1(@types/react@18.2.69)(react@18.2.0) + '@radix-ui/react-direction': 1.0.1(@types/react@18.2.69)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-roving-focus': 1.0.4(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-toggle': 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.69)(react@18.2.0) + '@types/react': 18.2.69 + '@types/react-dom': 18.2.22 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + + /@radix-ui/react-toggle@1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-Pkqg3+Bc98ftZGsl60CLANXQBBQ4W3mTFS9EJvNxKMZ7magklKV69/id1mlAlOFDDfHvlCms0fx8fA4CMKDJHg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.24.1 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.69)(react@18.2.0) + '@types/react': 18.2.69 + '@types/react-dom': 18.2.22 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@radix-ui/react-tooltip@1.0.7(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-lPh5iKNFVQ/jav/j6ZrWq3blfDJ0OH9R6FlNUHPMqdLuQ9vwDgFsRxvl8b7Asuy5c8xmoojHUxKHQSOAvMHxyw==} peerDependencies: @@ -2304,8 +2392,8 @@ packages: '@babel/runtime': 7.24.1 dev: false - /@radix-ui/themes@2.0.3(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-yaXQ8aWT2P1CQ0Xe6YCRD9HXsfMTvKkrIYkrc4aitCzhGTLS0sjtTqKmrxIWMVA+3DIbEuG9K/8aAMRJBhep8g==} + /@radix-ui/themes@3.0.0(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-PHRmrs9EQaO4h/gmRao1m/iZHnFcdB0L4u22OTU7EUeDDVBqzICrUhY0U+xihWTbiReVlt1b9AgM7CMiGXaJvw==} peerDependencies: '@types/react': '*' '@types/react-dom': '*' @@ -2324,30 +2412,38 @@ packages: '@radix-ui/react-aspect-ratio': 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-avatar': 1.0.4(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-checkbox': 1.0.4(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.2.69)(react@18.2.0) + '@radix-ui/react-context': 1.0.1(@types/react@18.2.69)(react@18.2.0) '@radix-ui/react-context-menu': 2.1.5(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-dialog': 1.0.5(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-direction': 1.0.1(@types/react@18.2.69)(react@18.2.0) '@radix-ui/react-dropdown-menu': 2.0.6(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-form': 0.0.3(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-hover-card': 1.0.7(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-navigation-menu': 1.1.4(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-popover': 1.0.7(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-portal': 1.0.4(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-progress': 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-radio-group': 1.1.3(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-roving-focus': 1.0.4(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-scroll-area': 1.0.5(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-select': 2.0.0(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) - '@radix-ui/react-separator': 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-slider': 1.1.2(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-slot': 1.0.2(@types/react@18.2.69)(react@18.2.0) '@radix-ui/react-switch': 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-tabs': 1.0.4(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) + '@radix-ui/react-toggle-group': 1.0.4(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-tooltip': 1.0.7(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.2.69)(react@18.2.0) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.2.69)(react@18.2.0) '@radix-ui/react-visually-hidden': 1.0.3(@types/react-dom@18.2.22)(@types/react@18.2.69)(react-dom@18.2.0)(react@18.2.0) '@types/react': 18.2.69 '@types/react-dom': 18.2.22 classnames: 2.5.1 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) + react-remove-scroll-bar: 2.3.4(@types/react@18.2.69)(react@18.2.0) dev: false /@rushstack/eslint-patch@1.8.0: @@ -2723,8 +2819,8 @@ packages: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} dev: true - /aria-hidden@1.2.3: - resolution: {integrity: sha512-xcLxITLe2HYa1cnYnwCjkOO1PqUHQpozB8x9AR0OgWN2woOBi5kSDVxKfd0b7sb1hw5qFeJhXm9H1nu3xSfLeQ==} + /aria-hidden@1.2.4: + resolution: {integrity: sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==} engines: {node: '>=10'} dependencies: tslib: 2.6.2 @@ -6095,8 +6191,8 @@ packages: /react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} - /react-remove-scroll-bar@2.3.6(@types/react@18.2.69)(react@18.2.0): - resolution: {integrity: sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==} + /react-remove-scroll-bar@2.3.4(@types/react@18.2.69)(react@18.2.0): + resolution: {integrity: sha512-63C4YQBUt0m6ALadE9XV56hV8BgJWDmmTPY758iIJjfQKt2nYwoUrPk0LXRXcB/yIj82T1/Ixfdpdk68LwIB0A==} engines: {node: '>=10'} peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 @@ -6123,10 +6219,10 @@ packages: dependencies: '@types/react': 18.2.69 react: 18.2.0 - react-remove-scroll-bar: 2.3.6(@types/react@18.2.69)(react@18.2.0) + react-remove-scroll-bar: 2.3.4(@types/react@18.2.69)(react@18.2.0) react-style-singleton: 2.2.1(@types/react@18.2.69)(react@18.2.0) tslib: 2.6.2 - use-callback-ref: 1.3.1(@types/react@18.2.69)(react@18.2.0) + use-callback-ref: 1.3.2(@types/react@18.2.69)(react@18.2.0) use-sidecar: 1.1.2(@types/react@18.2.69)(react@18.2.0) dev: false @@ -6903,6 +6999,10 @@ packages: safe-buffer: 5.2.1 dev: false + /twitter-api-v2@1.16.1: + resolution: {integrity: sha512-76hZsRmVdFQu2MvN2oBw0RjTsYmgqnef1bWb4/Ds54CrcTXvtTZFCp3d6FMdeKp9m2PIx2l9MIJWvs5PjQN/Dw==} + dev: false + /type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -7052,8 +7152,8 @@ packages: prepend-http: 2.0.0 dev: false - /use-callback-ref@1.3.1(@types/react@18.2.69)(react@18.2.0): - resolution: {integrity: sha512-Lg4Vx1XZQauB42Hw3kK7JM6yjVjgFmFC5/Ab797s79aARomD2nEErc4mCgM8EZrARLmmbWpi5DGCadmK50DcAQ==} + /use-callback-ref@1.3.2(@types/react@18.2.69)(react@18.2.0): + resolution: {integrity: sha512-elOQwe6Q8gqZgDA8mrh44qRTQqpIHDcZ3hXTLjBe1i4ph8XpNJnO+aQf3NaG+lriLopI4HMx9VjQLfPQ6vhnoA==} engines: {node: '>=10'} peerDependencies: '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 diff --git a/public/api/weekly-bg.png b/public/api/weekly-bg.png new file mode 100644 index 0000000..beb4303 Binary files /dev/null and b/public/api/weekly-bg.png differ diff --git a/scripts/docker-runtime-deps.mjs b/scripts/docker-runtime-deps.mjs index 4bc850b..89ed115 100644 --- a/scripts/docker-runtime-deps.mjs +++ b/scripts/docker-runtime-deps.mjs @@ -10,7 +10,12 @@ import { dirname, join } from "path"; const __dirname = dirname(new URL(import.meta.url).pathname); const packageJsonPath = join(__dirname, "..", "package.json"); -const dependenciesToInstall = ["husky", "better-sqlite3", "drizzle-orm"]; +const dependenciesToInstall = [ + "husky", + "better-sqlite3", + "drizzle-orm", + "@dotenvx/dotenvx", +]; async function main() { // Only keep the dependencies we need diff --git a/src/app/api/cron/weekly-users/route.ts b/src/app/api/cron/weekly-users/route.ts new file mode 100644 index 0000000..f33c0a3 --- /dev/null +++ b/src/app/api/cron/weekly-users/route.ts @@ -0,0 +1,48 @@ +import { db } from "@/db/db"; +import { transactionTable } from "@/db/schema"; +import { env } from "@/env"; +import { protocolsInfo } from "@/lib/protocols"; +import { sendTweet } from "@/lib/twitter"; +import { countDistinct, gt } from "drizzle-orm"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + // Last 7 days + const dateBegin = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + + const query = db + .select({ + protocol: transactionTable.protocol, + uniqueSenders: countDistinct(transactionTable.sender), + }) + .from(transactionTable) + .where(gt(transactionTable.timestamp, dateBegin)) + .groupBy(transactionTable.protocol); + const stats = await query; + + const data = stats.map((stat) => ({ + name: protocolsInfo[stat.protocol].name, + value: stat.uniqueSenders, + })); + + const params = new URLSearchParams(); + params.append("title", "Last 7 Days Unique Users"); + params.append("data", JSON.stringify(data)); + + const imageUrl = `${ + env.NEXT_PUBLIC_BASE_URL + }/api/images/weekly-users?${params.toString()}`; + + let message = `📈 Last 7 days unique users:\n`; + for (const stat of stats) { + message += `\n- @${protocolsInfo[stat.protocol].x.replace( + "https://twitter.com/", + "", + )}: ${stat.uniqueSenders.toLocaleString("en-US")} users`; + } + + await sendTweet({ message, images: [imageUrl] }); + + return Response.json({ ok: true, imageUrl }); +} diff --git a/src/app/api/images/weekly-users/route.tsx b/src/app/api/images/weekly-users/route.tsx new file mode 100644 index 0000000..b04b98d --- /dev/null +++ b/src/app/api/images/weekly-users/route.tsx @@ -0,0 +1,101 @@ +import { getWidthsFromValues } from "@/components/ui/BarList"; +import { env } from "@/env"; +import { cn } from "@/lib/cn"; +import { ImageResponse } from "next/og"; +import type { NextRequest } from "next/server"; +import { z } from "zod"; + +export const runtime = "edge"; +export const dynamic = "force-dynamic"; + +const size = { + width: 1012, + height: 506, +}; + +const schema = z.object({ + title: z.string().min(1).max(50), + data: z.array( + z.object({ + name: z.string().min(1).max(50), + value: z.number().int().min(0), + }), + ), +}); + +export async function GET(req: NextRequest) { + const url = new URL(req.url); + const searchParamsData = JSON.parse(url.searchParams.get("data") || "[]"); + const params = schema.safeParse({ + title: url.searchParams.get("title"), + data: searchParamsData, + }); + + if (!params.success) { + return Response.json( + { success: false, message: "Invalid parameters" }, + { status: 400 }, + ); + } + + const title = params.data.title; + const data = params.data.data; + const widths = getWidthsFromValues(data.map((item) => item.value)); + const orange = "#f76b15"; + const rowHeight = "h-[50px]"; + const rowGap = "24px"; + + return new ImageResponse( +
+
+
{title}
+ +
+
+ {data.map((item, idx) => ( +
+ {item.name} +
+ ))} +
+ +
+ {data.map((item) => ( +
+ {item.value.toLocaleString("en-US")} +
+ ))} +
+
+
+
, + { + ...size, + }, + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 740f2fc..a6b9fe5 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -50,7 +50,7 @@ export default async function HomePage() {
- + Transactions
diff --git a/src/app/protocols/[protocol]/page.tsx b/src/app/protocols/[protocol]/page.tsx index ff056fe..3f4f3aa 100644 --- a/src/app/protocols/[protocol]/page.tsx +++ b/src/app/protocols/[protocol]/page.tsx @@ -73,7 +73,7 @@ export default async function ProtocolPage({ priority />
- + {protocolInfo.name} @@ -118,7 +118,7 @@ export default async function ProtocolPage({
- + Transactions
diff --git a/src/components/ui/BarList.tsx b/src/components/ui/BarList.tsx index 8fd26af..36d457d 100644 --- a/src/components/ui/BarList.tsx +++ b/src/components/ui/BarList.tsx @@ -4,7 +4,7 @@ import { Text } from "@radix-ui/themes"; import Link from "next/link"; import { themeColors } from "./utils"; -const getWidthsFromValues = (dataValues: number[]) => { +export const getWidthsFromValues = (dataValues: number[]) => { let maxValue = Number.NEGATIVE_INFINITY; dataValues.forEach((value) => { maxValue = Math.max(maxValue, value); diff --git a/src/env.ts b/src/env.ts index be0ef28..3d3273e 100644 --- a/src/env.ts +++ b/src/env.ts @@ -5,6 +5,11 @@ export const env = createEnv({ server: { DATABASE_PATH: z.string().min(1), CHAINHOOKS_API_TOKEN: z.string().min(1), + CRON_API_TOKEN: z.string().min(1), + TWITTER_API_KEY: z.string().min(1), + TWITTER_API_SECRET_KEY: z.string().min(1), + TWITTER_ACCESS_TOKEN: z.string().min(1), + TWITTER_ACCESS_TOKEN_SECRET: z.string().min(1), }, client: { NEXT_PUBLIC_BASE_URL: z.string().min(1), diff --git a/src/lib/twitter.ts b/src/lib/twitter.ts new file mode 100644 index 0000000..24a292b --- /dev/null +++ b/src/lib/twitter.ts @@ -0,0 +1,38 @@ +import { env } from "@/env"; +import { EUploadMimeType, TwitterApi } from "twitter-api-v2"; + +const twitterClient = new TwitterApi({ + appKey: env.TWITTER_API_KEY, + appSecret: env.TWITTER_API_SECRET_KEY, + accessToken: env.TWITTER_ACCESS_TOKEN, + accessSecret: env.TWITTER_ACCESS_TOKEN_SECRET, +}); + +export const sendTweet = async ({ + message, + images, +}: { + message: string; + images?: string[]; +}) => { + if (env.TWITTER_API_KEY === "dev") { + console.log("Debug Send Tweet:\n", message); + return; + } + + const mediaIds = images + ? await Promise.all( + images.map(async (image) => { + const img = await fetch(image).then((res) => res.arrayBuffer()); + return twitterClient.v1.uploadMedia(Buffer.from(img), { + mimeType: EUploadMimeType.Png, + }); + }), + ) + : []; + + await twitterClient.v2.tweet({ + text: message, + media: { media_ids: mediaIds }, + }); +}; diff --git a/src/middleware.ts b/src/middleware.ts index b637660..65d01ac 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -3,19 +3,40 @@ import { env } from "./env"; export const config = { // Protect the entire /api/chainhooks/* with a bearer token - matcher: "/api/chainhooks/:function*", + // Protect the entire /api/cron/* with a bearer token + matcher: ["/api/chainhooks/:function*", "/api/cron/:function*"], }; export function middleware(request: NextRequest) { - const authorization = request.headers.get("Authorization") || ""; - const [scheme, token] = authorization.split(" "); + const endpoint = request.url.split("/"); + endpoint.splice(0, 3); - if (scheme !== "Bearer" || token !== env.CHAINHOOKS_API_TOKEN) { + // Should never happen + if (endpoint[0] !== "api") { return Response.json( - { success: false, message: "authentication failed" }, - { status: 401 }, + { success: false, message: "internal server error" }, + { status: 500 }, ); } + const authorization = request.headers.get("Authorization") || ""; + const [scheme, token] = authorization.split(" "); + + if (endpoint[1] === "chainhooks") { + if (scheme !== "Bearer" || token !== env.CHAINHOOKS_API_TOKEN) { + return Response.json( + { success: false, message: "authentication failed" }, + { status: 401 }, + ); + } + } else if (endpoint[1] === "cron") { + if (scheme !== "Bearer" || token !== env.CRON_API_TOKEN) { + return Response.json( + { success: false, message: "authentication failed" }, + { status: 401 }, + ); + } + } + return NextResponse.next(); }