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( +