diff --git a/.ebstalk.apps.env/scoutgameadmin.env b/.ebstalk.apps.env/scoutgameadmin.env index 4302bfadd0..f676ec2f2b 100644 --- a/.ebstalk.apps.env/scoutgameadmin.env +++ b/.ebstalk.apps.env/scoutgameadmin.env @@ -8,4 +8,5 @@ AUTH_SECRET="{{pull:secretsmanager:/io.cv.app/prd/auth_secret:SecretString:auth_ GITHUB_ACCESS_TOKEN="{{pull:secretsmanager:/io.cv.app/prd/github:SecretString:scoutgame_github_access_token}}" NEYNAR_API_KEY="{{pull:secretsmanager:/io.cv.app/prd/neynar:SecretString:neynar_api_key}}" REACT_APP_DECENT_API_KEY="{{pull:secretsmanager:/io.cv.app/prd/decent:SecretString:decent_api_key}}" -ALCHEMY_API_KEY="{{pull:secretsmanager:/io.cv.app/prd/alchemy:SecretString:alchemy_api_key}}" \ No newline at end of file +ALCHEMY_API_KEY="{{pull:secretsmanager:/io.cv.app/prd/alchemy:SecretString:alchemy_api_key}}" +AIRSTACK_API_KEY="{{pull:secretsmanager:/io.cv.app/prd/farcaster:SecretString:airstack_api_key}}" \ No newline at end of file diff --git a/apps/scoutgameadmin/app/api/partners/celo/route.ts b/apps/scoutgameadmin/app/api/partners/celo/route.ts new file mode 100644 index 0000000000..2b71e244d3 --- /dev/null +++ b/apps/scoutgameadmin/app/api/partners/celo/route.ts @@ -0,0 +1,54 @@ +import { prisma } from '@charmverse/core/prisma-client'; +import { getLastWeek } from '@packages/scoutgame/dates'; + +import { respondWithTSV } from 'lib/nextjs/respondWithTSV'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + const lastWeek = getLastWeek(); + const events = await prisma.builderEvent.findMany({ + where: { + githubEvent: { + repo: { + bonusPartner: 'celo' + } + }, + type: 'merged_pull_request', + week: lastWeek + }, + orderBy: { + createdAt: 'asc' + }, + select: { + createdAt: true, + builder: { + select: { + email: true, + username: true + } + }, + githubEvent: { + select: { + createdAt: true, + url: true, + repo: { + select: { + name: true, + owner: true + } + } + } + } + } + }); + const rows = events.map((event) => ({ + 'Farcaster Name': event.builder.username, + Email: event.builder.email, + Repo: `${event.githubEvent!.repo.owner}/${event.githubEvent!.repo.name}`, + Date: event.createdAt.toDateString(), + Link: event.githubEvent!.url + })); + + return respondWithTSV(rows, 'scout_users_export.tsv'); +} diff --git a/apps/scoutgameadmin/app/api/partners/moxie/route.ts b/apps/scoutgameadmin/app/api/partners/moxie/route.ts new file mode 100644 index 0000000000..5a5aeef056 --- /dev/null +++ b/apps/scoutgameadmin/app/api/partners/moxie/route.ts @@ -0,0 +1,202 @@ +import { log } from '@charmverse/core/log'; +import { prisma } from '@charmverse/core/prisma-client'; +import { currentSeason, getLastWeek } from '@packages/scoutgame/dates'; +import { RateLimit } from 'async-sema'; +import { uniq } from 'lodash'; + +import { respondWithTSV } from 'lib/nextjs/respondWithTSV'; + +export const dynamic = 'force-dynamic'; + +type MoxieBonusRow = { + 'Builder FID': number; + 'Builder username': string; + 'Builder event': string; + 'Scout FID': number; + 'Scout email': string; + 'Scout username': string; +}; + +export async function GET() { + const builders = await prisma.scout.findMany({ + where: { + builderStatus: 'approved' + }, + orderBy: { + farcasterId: 'asc' + }, + select: { + farcasterId: true, + username: true, + events: { + where: { + type: { + in: ['merged_pull_request', 'daily_commit'] + }, + week: getLastWeek() + } + }, + builderNfts: { + where: { + season: currentSeason + }, + select: { + nftSoldEvents: { + select: { + scout: { + select: { + farcasterId: true, + username: true + } + } + } + } + } + } + } + }); + const rows: MoxieBonusRow[] = []; + + await Promise.all( + builders.map(async (builder) => { + if (builder.farcasterId && builder.events.length > 0) { + // TODO: record moxie fan token data so we dont have to look it up again + const moxieNft = await getMoxieFanToken(builder.farcasterId); + if (moxieNft) { + const scoutFids = builder.builderNfts + .map((nft) => nft.nftSoldEvents.map((e) => e.scout.farcasterId)) + .flat() + .filter(Boolean); + for (const scoutFid of uniq(scoutFids)) { + const fanTokenAmount = await getMoxieFanTokenAmount({ + builderFid: builder.farcasterId, + scoutFid: scoutFid! + }); + const scout = await prisma.scout.findUnique({ + where: { + farcasterId: scoutFid! + } + }); + if (fanTokenAmount && scout) { + // console.log('found scout with fan token', builder.farcasterId, scoutFid, fanTokenAmount); + rows.push({ + 'Scout FID': scoutFid!, + 'Scout email': scout.email || '', + 'Scout username': scout.username, + 'Builder FID': builder.farcasterId, + 'Builder username': builder.username, + 'Builder event': + (builder.events[0]!.type === 'merged_pull_request' ? `PR on ` : `Commit on `) + + builder.events[0]!.createdAt.toDateString() + }); + } + } + } + } + }) + ); + + return respondWithTSV(rows, 'moxie_bonus_report.tsv'); +} + +type MoxieFanToken = { + currentPrice: number; + currentPriceInWei: number; + dailyVolumeChange: number; + fanTokenAddress: string; + fanTokenName: string; + fanTokenSymbol: string; + lockedTvl: number; + tlv: number; + tokenLockedAmount: number; + tokenUnlockedAmount: number; + totalSupply: number; + uniqueHolders: number; + unlockedTvl: number; +}; + +export async function getMoxieFanToken(farcasterId: number): Promise { + const query = ` + query MyQuery { + MoxieFanTokens( + input: {filter: {fanTokenSymbol: {_eq: "fid:${farcasterId}"}}, blockchain: ALL} + ) { + MoxieFanToken { + currentPrice + currentPriceInWei + dailyVolumeChange + fanTokenAddress + fanTokenName + fanTokenSymbol + lockedTvl + tlv + tokenLockedAmount + tokenUnlockedAmount + totalSupply + uniqueHolders + unlockedTvl + } + } + } + `; + const data = await getGQLQuery(query); + return data.data.MoxieFanTokens.MoxieFanToken?.[0] || null; +} + +export async function getMoxieFanTokenAmount({ + builderFid, + scoutFid +}: { + builderFid: number; + scoutFid: number; +}): Promise { + const query = ` + query GetPortfolioInfo { + MoxieUserPortfolios( + input: { + filter: { + fid: {_eq: "${scoutFid}"}, + fanTokenSymbol: { + # Fan Token to check, symbol will be based on types: + # - User: fid: + # - Channel: cid: + # - Network: id:farcaster + _eq: "fid:${builderFid}" + } + } + } + ) { + MoxieUserPortfolio { + amount: totalUnlockedAmount + } + } + } + `; + const data = await getGQLQuery(query); + // console.log('data', data); + return data.data.MoxieUserPortfolios.MoxieUserPortfolio?.[0]?.amount || 0; +} + +// at most, 10 req per second +// Moxy's rate limit is 3000/min and burst of 300/second. +// @source https://docs.airstack.xyz/airstack-docs-and-faqs/api-capabilities#rate-limits +const rateLimiter = RateLimit(50); + +async function getGQLQuery(query: string) { + await rateLimiter(); + const response = await fetch('https://api.airstack.xyz/gql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: process.env.AIRSTACK_API_KEY as string + }, + body: JSON.stringify({ query }) + }); + + if (!response.ok) { + log.debug('Error fetching Moxie NFT data:', { query, response }); + throw new Error(`HTTP error! status: ${response.status}`); + } + + return response.json(); +} diff --git a/apps/scoutgameadmin/app/api/users/export/route.ts b/apps/scoutgameadmin/app/api/users/export/route.ts index 7c4a5796f1..816e87abd5 100644 --- a/apps/scoutgameadmin/app/api/users/export/route.ts +++ b/apps/scoutgameadmin/app/api/users/export/route.ts @@ -1,8 +1,9 @@ import { prisma } from '@charmverse/core/prisma-client'; import { currentSeason } from '@packages/scoutgame/dates'; -import { stringify } from 'csv-stringify/sync'; import type { NextRequest } from 'next/server'; +import { respondWithTSV } from 'lib/nextjs/respondWithTSV'; + export const dynamic = 'force-dynamic'; type ScoutWithGithubUser = { @@ -24,7 +25,7 @@ type ScoutWithGithubUser = { weeklyBuilderRank?: number; }; -export async function GET(req: NextRequest) { +export async function GET() { const users = await prisma.scout.findMany({ select: { id: true, @@ -66,13 +67,6 @@ export async function GET(req: NextRequest) { nftsSold: user.userSeasonStats[0]?.nftsSold || 0, weeklyBuilderRank: user.userWeeklyStats[0]?.rank || undefined })); - const exportString = stringify(rows, { header: true, columns: Object.keys(rows[0]) }); - return new Response(exportString, { - status: 200, - headers: { - 'Content-Type': 'text/tsv', - 'Content-Disposition': 'attachment; filename=scout_users_export.tsv' - } - }); + return respondWithTSV(rows, 'scout_users_export.tsv'); } diff --git a/apps/scoutgameadmin/components/common/ExportButton.tsx b/apps/scoutgameadmin/components/common/ExportButton.tsx index d9f7a3cedd..0e1c33fe95 100644 --- a/apps/scoutgameadmin/components/common/ExportButton.tsx +++ b/apps/scoutgameadmin/components/common/ExportButton.tsx @@ -10,11 +10,13 @@ export function ExportButton({ children, src, filename, + onComplete, ...props }: { children: ReactNode; src: string; filename: string; + onComplete?: () => void; } & ButtonProps) { const { trigger, isMutating, error } = useGETtrigger(src); async function onClick() { @@ -26,6 +28,7 @@ export function ExportButton({ document.body.appendChild(link); link.click(); link.remove(); + onComplete?.(); } return ( diff --git a/apps/scoutgameadmin/components/repos/ReposDashboard.tsx b/apps/scoutgameadmin/components/repos/ReposDashboard.tsx index bc155125e6..75428f6426 100644 --- a/apps/scoutgameadmin/components/repos/ReposDashboard.tsx +++ b/apps/scoutgameadmin/components/repos/ReposDashboard.tsx @@ -18,7 +18,8 @@ import { TextField, Box, IconButton, - TableSortLabel + TableSortLabel, + Button } from '@mui/material'; import React, { useState, useMemo } from 'react'; @@ -27,10 +28,11 @@ import { useSearchRepos } from 'hooks/api/repos'; import { useDebouncedValue } from 'hooks/useDebouncedValue'; import type { Repo } from 'lib/repos/getRepos'; -import { AddRepoButton } from './AddRepoButton/AddRepoButton'; -import { DeleteRepoButton } from './DeleteRepoButton/DeleteRepoButton'; +import { AddRepoButton } from './components/AddRepoButton/AddRepoButton'; +import { DeleteRepoButton } from './components/DeleteRepoButton/DeleteRepoButton'; +import { HeaderActions } from './components/HeaderActions'; -type SortField = 'commits' | 'prs' | 'closedPrs' | 'contributors' | 'owner' | 'createdAt'; +type SortField = 'commits' | 'prs' | 'closedPrs' | 'contributors' | 'owner' | 'createdAt' | 'bonusPartner'; type SortOrder = 'asc' | 'desc'; export function ReposDashboard({ repos }: { repos: Repo[] }) { @@ -47,8 +49,10 @@ export function ReposDashboard({ repos }: { repos: Repo[] }) { return filteredRepos || []; } return repos.sort((a, b) => { - if (a[sortField] < b[sortField]) return sortOrder === 'asc' ? -1 : 1; - if (a[sortField] > b[sortField]) return sortOrder === 'asc' ? 1 : -1; + if (!a[sortField]) return sortOrder === 'asc' ? -1 : 1; + if (!b[sortField]) return sortOrder === 'asc' ? 1 : -1; + if (a[sortField]! < b[sortField]!) return sortOrder === 'asc' ? -1 : 1; + if (a[sortField]! > b[sortField]!) return sortOrder === 'asc' ? 1 : -1; // sort by name as a secondary sort if the field is the same if (a[sortField] === b[sortField]) return a.name.localeCompare(b.name); return 0; @@ -88,14 +92,7 @@ export function ReposDashboard({ repos }: { repos: Repo[] }) { } }} /> - - - Add Repos - - - Export Repos - - + @@ -111,15 +108,6 @@ export function ReposDashboard({ repos }: { repos: Repo[] }) { Name - - handleSort('createdAt')} - > - Imported at - - + + handleSort('bonusPartner')} + > + Bonus Partner + + Status @@ -172,11 +169,11 @@ export function ReposDashboard({ repos }: { repos: Repo[] }) { {repo.name} - {new Date(repo.createdAt).toLocaleDateString()}{repo.commits}{repo.prs}{repo.closedPrs}{repo.contributors} + {repo.bonusPartner ? repo.bonusPartner : ''} mutate()} repoId={repo.id} deletedAt={repo.deletedAt} /> diff --git a/apps/scoutgameadmin/components/repos/AddRepoButton/AddRepoButton.tsx b/apps/scoutgameadmin/components/repos/components/AddRepoButton/AddRepoButton.tsx similarity index 100% rename from apps/scoutgameadmin/components/repos/AddRepoButton/AddRepoButton.tsx rename to apps/scoutgameadmin/components/repos/components/AddRepoButton/AddRepoButton.tsx diff --git a/apps/scoutgameadmin/components/repos/AddRepoButton/AddRepoModal.tsx b/apps/scoutgameadmin/components/repos/components/AddRepoButton/AddRepoModal.tsx similarity index 100% rename from apps/scoutgameadmin/components/repos/AddRepoButton/AddRepoModal.tsx rename to apps/scoutgameadmin/components/repos/components/AddRepoButton/AddRepoModal.tsx diff --git a/apps/scoutgameadmin/components/repos/DeleteRepoButton/DeleteRepoButton.tsx b/apps/scoutgameadmin/components/repos/components/DeleteRepoButton/DeleteRepoButton.tsx similarity index 100% rename from apps/scoutgameadmin/components/repos/DeleteRepoButton/DeleteRepoButton.tsx rename to apps/scoutgameadmin/components/repos/components/DeleteRepoButton/DeleteRepoButton.tsx diff --git a/apps/scoutgameadmin/components/repos/components/HeaderActions.tsx b/apps/scoutgameadmin/components/repos/components/HeaderActions.tsx new file mode 100644 index 0000000000..b7b743748c --- /dev/null +++ b/apps/scoutgameadmin/components/repos/components/HeaderActions.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { ArrowDropDown as ArrowDropDownIcon, Add as AddIcon } from '@mui/icons-material'; +import { Menu, MenuItem, ListItemButton, Stack, Button } from '@mui/material'; +import { getLastWeek, getWeekStartEndFormatted, getDateFromISOWeek } from '@packages/scoutgame/dates'; +import React, { useState } from 'react'; + +import { ExportButton } from 'components/common/ExportButton'; + +import { AddRepoButton } from './AddRepoButton/AddRepoButton'; + +export function HeaderActions() { + const [anchorEl, setAnchorEl] = useState(null); + function closeMenu() { + setAnchorEl(null); + } + const lastWeek = getWeekStartEndFormatted(getDateFromISOWeek(getLastWeek()).toJSDate()); + return ( + + }> + Add + + + + + + All repositories + + + + + Celo Report ({lastWeek}) + + + + + Moxie Report ({lastWeek}) + + + + + ); +} diff --git a/apps/scoutgameadmin/components/users/UsersDashboard.tsx b/apps/scoutgameadmin/components/users/UsersDashboard.tsx index d7282bf0e0..b272d099f5 100644 --- a/apps/scoutgameadmin/components/users/UsersDashboard.tsx +++ b/apps/scoutgameadmin/components/users/UsersDashboard.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Clear as ClearIcon } from '@mui/icons-material'; +import { ArrowDropDown as ArrowDropDownIcon, Add as AddIcon, Clear as ClearIcon } from '@mui/icons-material'; import { CircularProgress, Container, @@ -98,11 +98,11 @@ export function UsersDashboard({ users }: { users: ScoutGameUser[] }) { }} /> - - Add User + }> + Add - Export Users + Export diff --git a/apps/scoutgameadmin/lib/nextjs/respondWithTSV.ts b/apps/scoutgameadmin/lib/nextjs/respondWithTSV.ts new file mode 100644 index 0000000000..2f465e783d --- /dev/null +++ b/apps/scoutgameadmin/lib/nextjs/respondWithTSV.ts @@ -0,0 +1,13 @@ +import { stringify } from 'csv-stringify/sync'; + +export async function respondWithTSV(rows: any[], filename: string) { + const exportString = stringify(rows, { header: true, columns: rows[0] ? Object.keys(rows[0]) : ['No Results'] }); + + return new Response(exportString, { + status: 200, + headers: { + 'Content-Type': 'text/tsv', + 'Content-Disposition': `attachment; filename=${filename}` + } + }); +} diff --git a/apps/scoutgameadmin/lib/repos/getRepos.ts b/apps/scoutgameadmin/lib/repos/getRepos.ts index fa6253ed84..b1ca0ae680 100644 --- a/apps/scoutgameadmin/lib/repos/getRepos.ts +++ b/apps/scoutgameadmin/lib/repos/getRepos.ts @@ -10,6 +10,7 @@ export type Repo = { prs: number; closedPrs: number; contributors: number; + bonusPartner: string | null; }; export async function getRepos({ searchString }: { searchString?: string } = {}): Promise { @@ -42,15 +43,24 @@ export async function getRepos({ searchString }: { searchString?: string } = {}) } } : { - events: { - some: { - githubUser: { - builderId: { - not: null + OR: [ + { + events: { + some: { + githubUser: { + builderId: { + not: null + } + } } } + }, + { + bonusPartner: { + not: null + } } - } + ] }, include: { events: true @@ -65,6 +75,7 @@ export async function getRepos({ searchString }: { searchString?: string } = {}) commits: repo.events.filter((event) => event.type === 'commit').length, prs: repo.events.filter((event) => event.type === 'merged_pull_request').length, closedPrs: repo.events.filter((event) => event.type === 'closed_pull_request').length, - contributors: new Set(repo.events.map((event) => event.createdBy)).size + contributors: new Set(repo.events.map((event) => event.createdBy)).size, + bonusPartner: repo.bonusPartner })); } diff --git a/apps/scoutgameadmin/package.json b/apps/scoutgameadmin/package.json index 53c3ed861e..a0ae5918b9 100644 --- a/apps/scoutgameadmin/package.json +++ b/apps/scoutgameadmin/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@mui/lab": "^6.0.0-beta.11", - "@packages/onchain": "^0.0.0" + "@packages/onchain": "^0.0.0", + "@packages/scoutgame": "^0.0.0" } } diff --git a/apps/scoutgameadmin/scripts/moxie.ts b/apps/scoutgameadmin/scripts/moxie.ts new file mode 100644 index 0000000000..521564c901 --- /dev/null +++ b/apps/scoutgameadmin/scripts/moxie.ts @@ -0,0 +1,8 @@ +import { getMoxieFanToken, getMoxieFanTokenAmount } from '../app/api/partners/moxie/route'; + +(async () => { + const nft = await getMoxieFanToken(603); + console.log(nft); + const amount = await getMoxieFanTokenAmount({ builderFid: 603, scoutFid: 602 }); + console.log(amount); +})(); diff --git a/package-lock.json b/package-lock.json index 0f440b1cf4..158255a05e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -781,7 +781,8 @@ "version": "0.1.0", "dependencies": { "@mui/lab": "^6.0.0-beta.11", - "@packages/onchain": "^0.0.0" + "@packages/onchain": "^0.0.0", + "@packages/scoutgame": "^0.0.0" } }, "apps/scoutgameadmin/node_modules/@mui/core-downloads-tracker": { @@ -113367,7 +113368,8 @@ "version": "file:apps/scoutgameadmin", "requires": { "@mui/lab": "^6.0.0-beta.11", - "@packages/onchain": "^0.0.0" + "@packages/onchain": "^0.0.0", + "@packages/scoutgame": "^0.0.0" }, "dependencies": { "@mui/core-downloads-tracker": { diff --git a/packages/scoutgame/src/dates.ts b/packages/scoutgame/src/dates.ts index 955266d889..e9ac7464b7 100644 --- a/packages/scoutgame/src/dates.ts +++ b/packages/scoutgame/src/dates.ts @@ -68,7 +68,12 @@ export function getWeekStartEnd(date: Date) { return { start: startOfWeek, end: endOfWeek }; } -export function getStartOfSeason(week: ISOWeek) { +export function getWeekStartEndFormatted(date: Date) { + const { start, end } = getWeekStartEnd(date); + return `${start.toFormat('MMM, dd')} - ${end.toFormat('MMM, dd')}`; +} + +export function getStartOfSeason(week: Season) { return getDateFromISOWeek(week); }