From 6d873206f327f97d9f746dbf2e074b768eadae2f Mon Sep 17 00:00:00 2001 From: motechFR Date: Thu, 10 Oct 2024 18:51:42 -0500 Subject: [PATCH 01/38] Nft/use hooks (#4784) * Use hook for user balances * Use hooks for token balances, and prepare the decent transaction ourselves * Cleanup the form * Cleanup purchase form * Add better error handling in decent * Add token balances across chains * Localise serialiser into the decent tx preparer --- .../ChainSelector/ChainComponent.tsx | 8 +- .../ChainSelector/ChainSelector.tsx | 63 ++++-- .../components/ChainSelector/chains.ts | 9 +- .../components/NFTPurchaseForm.tsx | 189 ++++-------------- .../hooks/useDecentTransaction.tsx | 115 +++++++++++ .../hooks/useGetTokenBalances.tsx | 28 +++ .../scripts/syncUserNFTsFromOnchainData.ts | 2 +- .../scoutgame/src/builderNfts/constants.ts | 5 + 8 files changed, 247 insertions(+), 172 deletions(-) create mode 100644 apps/scoutgame/components/common/NFTPurchaseDialog/hooks/useDecentTransaction.tsx create mode 100644 apps/scoutgame/components/common/NFTPurchaseDialog/hooks/useGetTokenBalances.tsx diff --git a/apps/scoutgame/components/common/NFTPurchaseDialog/components/ChainSelector/ChainComponent.tsx b/apps/scoutgame/components/common/NFTPurchaseDialog/components/ChainSelector/ChainComponent.tsx index f87911d986..7a65f52dcb 100644 --- a/apps/scoutgame/components/common/NFTPurchaseDialog/components/ChainSelector/ChainComponent.tsx +++ b/apps/scoutgame/components/common/NFTPurchaseDialog/components/ChainSelector/ChainComponent.tsx @@ -10,9 +10,11 @@ export function ChainComponent({ selected }: { chain: ChainWithCurrency; - balance?: string | number; + balance?: string | number | bigint; selected?: boolean; }) { + const balanceNormalised = typeof balance === 'bigint' ? balance.toString() : balance; + return ( @@ -21,9 +23,9 @@ export function ChainComponent({ {chain.currency} on {chain.name} - {typeof balance !== 'undefined' && ( + {typeof balanceNormalised !== 'undefined' && ( - Balance: {balance} {chain.currency} + Balance: {balanceNormalised} {chain.currency} )} diff --git a/apps/scoutgame/components/common/NFTPurchaseDialog/components/ChainSelector/ChainSelector.tsx b/apps/scoutgame/components/common/NFTPurchaseDialog/components/ChainSelector/ChainSelector.tsx index c3ffd1c1a4..469fe1c111 100644 --- a/apps/scoutgame/components/common/NFTPurchaseDialog/components/ChainSelector/ChainSelector.tsx +++ b/apps/scoutgame/components/common/NFTPurchaseDialog/components/ChainSelector/ChainSelector.tsx @@ -2,9 +2,12 @@ import { MenuItem, Select, Stack, Typography } from '@mui/material'; import type { SelectProps } from '@mui/material/Select'; import type { ReactNode, Ref } from 'react'; import { forwardRef } from 'react'; +import type { Address } from 'viem'; + +import { useGetTokenBalances } from '../../hooks/useGetTokenBalances'; import { ChainComponent } from './ChainComponent'; -import { getChainOptions } from './chains'; +import { ETH_NATIVE_ADDRESS, getChainOptions } from './chains'; export type SelectedPaymentOption = { chainId: number; currency: 'ETH' | 'USDC' }; @@ -18,6 +21,7 @@ function SelectField( balance, onSelectChain, value, + address, ...props }: Omit, 'onClick' | 'value'> & { helperMessage?: ReactNode; @@ -25,6 +29,7 @@ function SelectField( value: SelectedPaymentOption; useTestnets?: boolean; balance?: string; + address?: Address; }, ref: Ref ) { @@ -32,6 +37,8 @@ function SelectField( const chainOpts = getChainOptions({ useTestnets }); + const { tokens } = useGetTokenBalances({ address: address as Address }); + return ( fullWidth @@ -66,23 +73,43 @@ function SelectField( Select a Chain - {chainOpts.map((_chain) => ( - { - ev.preventDefault(); - ev.stopPropagation(); - onSelectChain({ chainId: _chain.id, currency: _chain.currency }); - }} - sx={{ py: 1.5 }} - > - - - ))} + {chainOpts.map((_chain) => { + const _tokenBalanceInfo = tokens?.find( + (t) => + t.chainId === _chain.id && + (_chain.currency === 'ETH' + ? t.address === ETH_NATIVE_ADDRESS + : t.address?.toLowerCase() === _chain.usdcAddress.toLowerCase()) + ); + let _balance = Number(_tokenBalanceInfo?.balance); + + if (_balance) { + if (_chain.currency === 'ETH') { + _balance /= 1e18; + } else { + _balance /= 1e6; + } + } + + return ( + { + ev.preventDefault(); + ev.stopPropagation(); + onSelectChain({ chainId: _chain.id, currency: _chain.currency }); + }} + sx={{ py: 1.5 }} + > + + + ); + })} ); } diff --git a/apps/scoutgame/components/common/NFTPurchaseDialog/components/ChainSelector/chains.ts b/apps/scoutgame/components/common/NFTPurchaseDialog/components/ChainSelector/chains.ts index ff7db98d13..7447e27991 100644 --- a/apps/scoutgame/components/common/NFTPurchaseDialog/components/ChainSelector/chains.ts +++ b/apps/scoutgame/components/common/NFTPurchaseDialog/components/ChainSelector/chains.ts @@ -1,4 +1,5 @@ import { useTestnets } from '@packages/scoutgame/builderNfts/constants'; +import type { Address } from 'viem'; import type { Chain } from 'viem/chains'; import { arbitrum, @@ -15,6 +16,8 @@ import { export type ChainOption = { name: string; id: number; icon: string; chain: Chain; usdcAddress: string }; +export const ETH_NATIVE_ADDRESS = '0x0000000000000000000000000000000000000000'; + export const chainOptionsMainnet: ChainOption[] = [ { name: 'Optimism', @@ -85,12 +88,12 @@ export type ChainWithCurrency = ChainOption & { currency: AvailableCurrency }; export type SelectedPaymentOption = { chainId: number; currency: AvailableCurrency }; -export function getCurrencyContract({ currency, chainId }: SelectedPaymentOption): string { +export function getCurrencyContract({ currency, chainId }: SelectedPaymentOption): Address { if (currency === 'ETH') { - return '0x0000000000000000000000000000000000000000'; + return ETH_NATIVE_ADDRESS; } - return getChainOptions({ useTestnets }).find((chain) => chain.id === chainId)?.usdcAddress || ''; + return (getChainOptions({ useTestnets }).find((chain) => chain.id === chainId)?.usdcAddress || '') as Address; } export function getChainOptions(opts: { useTestnets?: boolean } = { useTestnets: false }): ChainWithCurrency[] { diff --git a/apps/scoutgame/components/common/NFTPurchaseDialog/components/NFTPurchaseForm.tsx b/apps/scoutgame/components/common/NFTPurchaseDialog/components/NFTPurchaseForm.tsx index 5af297bae3..a821553ff0 100644 --- a/apps/scoutgame/components/common/NFTPurchaseDialog/components/NFTPurchaseForm.tsx +++ b/apps/scoutgame/components/common/NFTPurchaseDialog/components/NFTPurchaseForm.tsx @@ -2,9 +2,10 @@ import env from '@beam-australia/react-env'; import { log } from '@charmverse/core/log'; +import { arrayUtils } from '@charmverse/core/utilities'; import { ActionType, ChainId } from '@decent.xyz/box-common'; -import type { UseBoxActionArgs } from '@decent.xyz/box-hooks'; -import { BoxHooksContextProvider, useBoxAction } from '@decent.xyz/box-hooks'; +import type { UseBoxActionArgs, UserBalanceArgs } from '@decent.xyz/box-hooks'; +import { BoxHooksContextProvider, useBoxAction, useUsersBalances } from '@decent.xyz/box-hooks'; import { InfoOutlined as InfoIcon } from '@mui/icons-material'; import { LoadingButton } from '@mui/lab'; import { @@ -29,6 +30,7 @@ import { } from '@packages/scoutgame/builderNfts/constants'; import { UsdcErc20ABIClient } from '@packages/scoutgame/builderNfts/usdcContractApiClient'; import { convertCostToPointsWithDiscount, convertCostToUsd } from '@packages/scoutgame/builderNfts/utils'; +import { prettyPrint } from '@packages/utils/strings'; import { getPublicClient } from '@root/lib/blockchain/publicClient'; import Image from 'next/image'; import Link from 'next/link'; @@ -45,10 +47,12 @@ import { purchaseWithPointsAction } from 'lib/builderNFTs/purchaseWithPointsActi import { saveDecentTransactionAction } from 'lib/builderNFTs/saveDecentTransactionAction'; import type { MinimalUserInfo } from 'lib/users/interfaces'; +import { useDecentTransaction } from '../hooks/useDecentTransaction'; import { useGetERC20Allowance } from '../hooks/useGetERC20Allowance'; +import { useGetTokenBalances } from '../hooks/useGetTokenBalances'; import type { ChainOption } from './ChainSelector/chains'; -import { getChainOptions, getCurrencyContract } from './ChainSelector/chains'; +import { chainOptionsMainnet, ETH_NATIVE_ADDRESS, getChainOptions, getCurrencyContract } from './ChainSelector/chains'; import type { SelectedPaymentOption } from './ChainSelector/ChainSelector'; import { BlockchainSelect } from './ChainSelector/ChainSelector'; import { ERC20ApproveButton } from './ERC20Approve'; @@ -94,6 +98,8 @@ export function NFTPurchaseFormContent({ builder }: NFTPurchaseProps) { currency: 'ETH' }); + const { tokens: userTokenBalances } = useGetTokenBalances({ address: address as Address }); + const [isFetchingPrice, setIsFetchingPrice] = useState(false); const [fetchError, setFetchError] = useState(null); @@ -103,10 +109,6 @@ export function NFTPurchaseFormContent({ builder }: NFTPurchaseProps) { const [paymentMethod, setPaymentMethod] = useState<'points' | 'wallet'>('wallet'); - const [balances, setBalances] = useState<{ usdc: bigint; eth: bigint; chainId: number; address: string } | null>( - null - ); - // Data from onchain const [purchaseCost, setPurchaseCost] = useState(BigInt(0)); const [builderTokenId, setBuilderTokenId] = useState(BigInt(0)); @@ -169,51 +171,6 @@ export function NFTPurchaseFormContent({ builder }: NFTPurchaseProps) { } }); - const refreshBalance = useCallback(async () => { - const chainOption = getChainOptions({ useTestnets }).find( - (opt) => opt.id === selectedPaymentOption.chainId - ) as ChainOption; - - const chain = chainOption?.chain; - - const _chainId = chain?.id; - - if (!_chainId) { - return; - } - - const client = new UsdcErc20ABIClient({ - chain, - contractAddress: chainOption.usdcAddress as `0x${string}`, - publicClient: getPublicClient(_chainId) - }); - - const usdcBalance = await client.balanceOf({ args: { account: address as `0x${string}` } }); - - const ethBalance = await getPublicClient(_chainId).getBalance({ - address: address as `0x${string}` - }); - - const newBalances = { - usdc: usdcBalance, - eth: ethBalance, - chainId: _chainId, - address: address as `0x${string}` - }; - - setBalances(newBalances); - - return newBalances; - }, [address, selectedPaymentOption.chainId, selectedPaymentOption.currency]); - - useEffect(() => { - if (selectedPaymentOption) { - refreshBalance().catch((err) => { - log.error('Error refreshing balance', { error: err }); - }); - } - }, [selectedPaymentOption, address]); - const { sendTransaction } = useSendTransaction(); const refreshAsk = useCallback( @@ -257,81 +214,34 @@ export function NFTPurchaseFormContent({ builder }: NFTPurchaseProps) { } }, [tokensToBuy, builderTokenId, refreshAsk]); - /** TODO - Use this payload when we resume calling the contract directly - { - enable: !!address && !!purchaseCost, - actionType: ActionType.EvmFunction, - sender: address as string, - srcToken: '0x0000000000000000000000000000000000000000', - dstToken: '0x0b2c639c533813f4aa9d7837caf62653d097ff85', - slippage: 1, - - srcChainId: ChainId.BASE, - dstChainId: ChainId.OPTIMISM, - actionConfig: { - chainId: ChainId.OPTIMISM, - contractAddress: '0x7df4d9f54a5cddfef50a032451f694d6345c60af', - cost: { - amount: purchaseCost, - isNative: false, - tokenAddress: '0x0b2c639c533813f4aa9d7837caf62653d097ff85' - }, - signature: 'function mintBuilderNft(uint256 tokenId, uint256 amount, string calldata scout) external', - args: [BigInt(1), BigInt(1), 'c42efe4a-b385-488e-a5ca-135ecec0f810'] - } - } - */ - const enableNftButton = !!address && !!purchaseCost && !!user; - const decentAPIParams: UseBoxActionArgs = { - enable: enableNftButton, - sender: address as `0x${string}`, - srcToken: getCurrencyContract(selectedPaymentOption), - dstToken: optimismUsdcContractAddress, - srcChainId: selectedPaymentOption.chainId, - dstChainId: ChainId.OPTIMISM, - slippage: 1, - actionType: ActionType.NftMint, - // @ts-ignore - actionConfig: { - chainId: ChainId.OPTIMISM, - contractAddress: getBuilderContractAddress(), - cost: { - amount: purchaseCost, - isNative: false, - tokenAddress: optimismUsdcContractAddress - }, - signature: 'function mint(address account, uint256 tokenId, uint256 amount, string scout)', - args: [address, BigInt(builderTokenId), BigInt(tokensToBuy), user?.id] - } - }; + const { decentSdkError, isLoadingDecentSdk, decentTransactionInfo } = useDecentTransaction({ + address: address as Address, + builderTokenId, + scoutId: user?.id as string, + paymentAmountOut: purchaseCost, + sourceChainId: selectedPaymentOption.chainId, + sourceToken: getCurrencyContract(selectedPaymentOption), + tokensToPurchase: BigInt(tokensToBuy) + }); - const { error: decentSdkError, isLoading: isLoadingDecentSdk, actionResponse } = useBoxAction(decentAPIParams); + const selectedChainCurrency = getCurrencyContract(selectedPaymentOption) as Address; const { allowance, refreshAllowance, isLoadingAllowance } = useGetERC20Allowance({ chainId: selectedPaymentOption.chainId, - erc20Address: - selectedPaymentOption.currency === 'USDC' - ? (getCurrencyContract({ - chainId: selectedPaymentOption.chainId, - currency: 'USDC' - }) as Address) - : null, + erc20Address: selectedPaymentOption.currency === 'USDC' ? selectedChainCurrency : null, owner: address as Address, - spender: actionResponse?.tx.to as Address + spender: decentTransactionInfo?.tx.to as Address }); - const amountToPay = actionResponse?.tokenPayment?.amount; - const isEthPayment = !!actionResponse?.tokenPayment?.isNative; - const balanceDataFromCorrectChain = balances?.chainId === selectedPaymentOption.chainId; + const balanceInfo = userTokenBalances?.find( + (_token) => _token.chainId === selectedPaymentOption.chainId && _token.address === selectedChainCurrency + ); + + const amountToPay = BigInt(decentTransactionInfo?.tokenPayment?.amount?.toString().replace('n', '') || 0); - const hasSufficientBalance = - !amountToPay || !balanceDataFromCorrectChain - ? false - : isEthPayment - ? balances?.eth >= amountToPay - : balances?.usdc >= amountToPay; + const hasInsufficientBalance = !!amountToPay && !!balanceInfo && balanceInfo.balance < amountToPay; const handlePurchase = async () => { if (paymentMethod === 'points') { @@ -341,7 +251,7 @@ export function NFTPurchaseFormContent({ builder }: NFTPurchaseProps) { amount: tokensToBuy }); } else { - if (!actionResponse?.tx) { + if (!decentTransactionInfo?.tx) { return; } @@ -351,9 +261,9 @@ export function NFTPurchaseFormContent({ builder }: NFTPurchaseProps) { sendTransaction( { - to: actionResponse.tx.to as Address, - data: actionResponse.tx.data as any, - value: (actionResponse.tx as any).value + to: decentTransactionInfo.tx.to as Address, + data: decentTransactionInfo.tx.data as any, + value: (decentTransactionInfo.tx as any).value }, { onSuccess: async (data) => { @@ -380,22 +290,13 @@ export function NFTPurchaseFormContent({ builder }: NFTPurchaseProps) { setSubmitError( err.message || 'Something went wrong. Check your wallet is connected and has a sufficient balance' ); - log.error('Creating a mint transaction failed', { actionResponse, error: err }); + log.error('Creating a mint transaction failed', { decentTransactionInfo, error: err }); } } ); } }; - useEffect(() => { - if (decentSdkError) { - log.error('Error on NFT Purchase calling useBoxAction from Decent SDK', { - params: decentAPIParams, - error: decentSdkError - }); - } - }, [decentSdkError]); - const isLoading = isSavingDecentTransaction || isLoadingDecentSdk || @@ -403,12 +304,11 @@ export function NFTPurchaseFormContent({ builder }: NFTPurchaseProps) { isExecutingTransaction || isExecutingPointsPurchase; - const displayedBalance = - balances?.chainId !== selectedPaymentOption.chainId || balances.address !== address - ? undefined - : selectedPaymentOption.currency === 'ETH' - ? (Number(balances?.eth || 0) / 1e18).toFixed(4) - : (Number(balances.usdc || 0) / 1e6).toFixed(2); + const displayedBalance = !balanceInfo + ? undefined + : selectedPaymentOption.currency === 'ETH' + ? (Number(balanceInfo.balance || 0) / 1e18).toFixed(4) + : (Number(balanceInfo.balance || 0) / 1e6).toFixed(2); const [selectedQuantity, setSelectedQuantity] = useState(1); const [customQuantity, setCustomQuantity] = useState(2); @@ -621,11 +521,12 @@ export function NFTPurchaseFormContent({ builder }: NFTPurchaseProps) { value={selectedPaymentOption} balance={displayedBalance} useTestnets={useTestnets} + address={address} onSelectChain={(_paymentOption) => { setSelectedPaymentOption(_paymentOption); }} /> - {!hasSufficientBalance && balanceDataFromCorrectChain && !!amountToPay ? ( + {hasInsufficientBalance ? ( Insufficient balance @@ -639,11 +540,7 @@ export function NFTPurchaseFormContent({ builder }: NFTPurchaseProps) { )} - {!approvalRequired || - isExecutingTransaction || - isExecutingPointsPurchase || - // Show disabled buy button if the user has insufficient balance, instead of the approve button - (!hasSufficientBalance && balanceDataFromCorrectChain) ? ( + {!approvalRequired || isExecutingTransaction || isExecutingPointsPurchase ? ( Buy ) : ( { + const DECENT_API_KEY = getDecentApiKey(); + + function _appendQuery(path: string, data: any) { + const queryString = Object.keys(data) + .filter((key) => !!data[key]) + .map((key) => { + const value = data[key]; + return Array.isArray(value) + ? `${value.map((v: string) => `${key}=${v}`).join('&')}` + : typeof value === 'object' + ? `${key}=${JSON.stringify(value, (_key, val) => (typeof val === 'bigint' ? `${val.toString()}n` : val))}` + : `${key}=${encodeURIComponent(value)}`; + }) + .join('&'); + return `${path}${queryString ? `?${queryString}` : ''}`; + } + + const basePath = 'https://box-v3-2-0.api.decent.xyz/api/getBoxAction'; + + const response = await GET(_appendQuery(basePath, { arguments: txConfig }), undefined, { + headers: { + 'x-api-key': DECENT_API_KEY + }, + credentials: 'omit' + }); + + return response; +} + +export function useDecentTransaction({ + address, + paymentAmountOut, + sourceChainId, + sourceToken, + builderTokenId, + scoutId, + tokensToPurchase +}: DecentTransactionProps) { + const decentAPIParams: BoxActionRequest = { + sender: address as `0x${string}`, + srcToken: sourceToken, + dstToken: optimismUsdcContractAddress, + srcChainId: sourceChainId, + dstChainId: builderNftChain.id, + slippage: 1, + actionType: ActionType.NftMint, + // @ts-ignore + actionConfig: { + chainId: optimism.id, + contractAddress: getBuilderContractAddress(), + cost: { + amount: paymentAmountOut, + isNative: false, + tokenAddress: optimismUsdcContractAddress + }, + signature: 'function mint(address account, uint256 tokenId, uint256 amount, string scout)', + args: [address, builderTokenId, tokensToPurchase, scoutId] + } + }; + + const { + error: decentSdkError, + isLoading: isLoadingDecentSdk, + data: decentTransactionInfo + } = useSWR( + address ? `buy-token-${builderTokenId}-${tokensToPurchase}-${sourceChainId}-${sourceToken}-${scoutId}` : null, + () => + prepareDecentTransaction({ + txConfig: decentAPIParams + }).catch((error) => { + log.error(`Error preparing decent transaction`, { error, decentAPIParams }); + throw error; + }), + { + shouldRetryOnError: (error) => { + log.info(`Retrying decent tx`, { decentAPIParams, error }); + return true; + }, + errorRetryInterval: 1000 + } + ); + + return { + decentSdkError, + isLoadingDecentSdk, + decentTransactionInfo + }; +} diff --git a/apps/scoutgame/components/common/NFTPurchaseDialog/hooks/useGetTokenBalances.tsx b/apps/scoutgame/components/common/NFTPurchaseDialog/hooks/useGetTokenBalances.tsx new file mode 100644 index 0000000000..4c167b833a --- /dev/null +++ b/apps/scoutgame/components/common/NFTPurchaseDialog/hooks/useGetTokenBalances.tsx @@ -0,0 +1,28 @@ +import { arrayUtils } from '@charmverse/core/utilities'; +import { ChainId } from '@decent.xyz/box-common'; +import type { UserBalanceArgs } from '@decent.xyz/box-hooks'; +import { useUsersBalances } from '@decent.xyz/box-hooks'; +import type { Address } from 'viem'; + +import { chainOptionsMainnet, ETH_NATIVE_ADDRESS, getChainOptions } from '../components/ChainSelector/chains'; + +export function useGetTokenBalances({ address }: { address: Address }) { + const args: UserBalanceArgs = { + chainId: ChainId.OPTIMISM, + selectChains: arrayUtils.uniqueValues(getChainOptions().map((opt) => opt.id)), + address, + enable: !!address, + selectTokens: arrayUtils.uniqueValues( + chainOptionsMainnet + .map((opt) => { + if (opt.usdcAddress) { + return [ETH_NATIVE_ADDRESS, opt.usdcAddress]; + } + return [ETH_NATIVE_ADDRESS]; + }) + .flat() + ) + }; + + return useUsersBalances(args); +} diff --git a/apps/scoutgamecron/src/scripts/syncUserNFTsFromOnchainData.ts b/apps/scoutgamecron/src/scripts/syncUserNFTsFromOnchainData.ts index 6cdfd1bf40..31ab426953 100644 --- a/apps/scoutgamecron/src/scripts/syncUserNFTsFromOnchainData.ts +++ b/apps/scoutgamecron/src/scripts/syncUserNFTsFromOnchainData.ts @@ -62,4 +62,4 @@ async function syncUserNFTsFromOnchainData({username, scoutId}: {username?: stri } -syncUserNFTsFromOnchainData({ username: 'cryptomobile' }).then(console.log) \ No newline at end of file +// syncUserNFTsFromOnchainData({ username: 'cryptomobile' }).then(console.log) \ No newline at end of file diff --git a/packages/scoutgame/src/builderNfts/constants.ts b/packages/scoutgame/src/builderNfts/constants.ts index c52c2e75cf..c4701c58f1 100644 --- a/packages/scoutgame/src/builderNfts/constants.ts +++ b/packages/scoutgame/src/builderNfts/constants.ts @@ -41,6 +41,11 @@ export const builderSmartContractOwnerKey = process.env.BUILDER_SMART_CONTRACT_O // Actual target wallet - Scoutgame.eth export const treasuryAddress = '0x93326D53d1E8EBf0af1Ff1B233c46C67c96e4d8D'; + +export function getDecentApiKey() { + const apiKey = env('DECENT_API_KEY') || process.env.REACT_APP_DECENT_API_KEY; + return apiKey; +} // const serverClient = getWalletClient({ chainId: builderNftChain.id, privateKey: builderSmartContractOwnerKey }); // const apiClient = new BuilderNFTSeasonOneClient({ From faf6fe39a852eb9295bdef0cc06917f3bbe0e696 Mon Sep 17 00:00:00 2001 From: Safwan Shaheer Date: Fri, 11 Oct 2024 16:58:23 +0600 Subject: [PATCH 02/38] Custom columns for my work tab proposal tables (#4782) * Bumped core * Fetch custom columns for my work proposals * Render custom columns on my work tab proposal tables * Bumped core * Added custom column sorting * Center aligned your review icons * Updated UI --------- Co-authored-by: mattcasey --- .../components/ViewFilterControl.tsx | 4 +- .../components/ViewSortControl.tsx | 5 +- .../ActionableProposalsTable.tsx | 29 +++- .../CustomColumnTableCells.tsx | 31 +++++ .../UserProposalsTables/ProposalsTable.tsx | 28 +++- .../UserProposalsTables.tsx | 14 +- lib/proposals/getUserProposals.ts | 124 ++++++++++++++++-- package-lock.json | 2 +- package.json | 2 +- scripts/query.ts | 7 +- 10 files changed, 214 insertions(+), 32 deletions(-) create mode 100644 components/proposals/components/UserProposalsTables/CustomColumnTableCells.tsx diff --git a/components/common/DatabaseEditor/components/ViewFilterControl.tsx b/components/common/DatabaseEditor/components/ViewFilterControl.tsx index 3b410900d0..4853fb36be 100644 --- a/components/common/DatabaseEditor/components/ViewFilterControl.tsx +++ b/components/common/DatabaseEditor/components/ViewFilterControl.tsx @@ -1,9 +1,7 @@ import { FilterList } from '@mui/icons-material'; -import { Popover, Tooltip } from '@mui/material'; +import { Popover } from '@mui/material'; import { bindPopover, bindTrigger, usePopupState } from 'material-ui-popup-state/hooks'; -import { FormattedMessage } from 'react-intl'; -import { Button } from 'components/common/Button'; import { useViewFilter } from 'hooks/useViewFilter'; import type { Board } from 'lib/databases/board'; import type { BoardView } from 'lib/databases/boardView'; diff --git a/components/common/DatabaseEditor/components/ViewSortControl.tsx b/components/common/DatabaseEditor/components/ViewSortControl.tsx index dc532788cf..3c79bf7ab6 100644 --- a/components/common/DatabaseEditor/components/ViewSortControl.tsx +++ b/components/common/DatabaseEditor/components/ViewSortControl.tsx @@ -1,10 +1,7 @@ import { Menu } from '@mui/material'; -import { bindMenu, bindTrigger, type PopupState } from 'material-ui-popup-state/hooks'; -import { usePopupState } from 'material-ui-popup-state/hooks'; +import { bindMenu, bindTrigger, usePopupState } from 'material-ui-popup-state/hooks'; import { TbArrowsSort } from 'react-icons/tb'; -import { FormattedMessage } from 'react-intl'; -import { Button } from 'components/common/Button'; import { useViewSortOptions } from 'hooks/useViewSortOptions'; import type { Board } from 'lib/databases/board'; import type { BoardView } from 'lib/databases/boardView'; diff --git a/components/proposals/components/UserProposalsTables/ActionableProposalsTable.tsx b/components/proposals/components/UserProposalsTables/ActionableProposalsTable.tsx index 9acc357e6a..de531eecc5 100644 --- a/components/proposals/components/UserProposalsTables/ActionableProposalsTable.tsx +++ b/components/proposals/components/UserProposalsTables/ActionableProposalsTable.tsx @@ -2,15 +2,22 @@ import { ThumbUpOutlined as ApprovedIcon, HighlightOff as RejectedIcon } from '@ import ProposalIcon from '@mui/icons-material/TaskOutlined'; import { Box, Button, Card, TableBody, TableCell, TableHead, TableRow, Typography } from '@mui/material'; import { Stack } from '@mui/system'; -import type { UserProposal } from '@root/lib/proposals/getUserProposals'; +import type { CustomColumn, UserProposal } from '@root/lib/proposals/getUserProposals'; import { useRouter } from 'next/router'; import Link from 'components/common/Link'; import { evaluationIcons } from 'components/settings/proposals/constants'; +import { CustomColumnTableCells } from './CustomColumnTableCells'; import { OpenButton, StyledTable, StyledTableRow } from './ProposalsTable'; -export function ActionableProposalsTable({ proposals }: { proposals: UserProposal[] }) { +export function ActionableProposalsTable({ + proposals, + customColumns +}: { + proposals: UserProposal[]; + customColumns: CustomColumn[]; +}) { const router = useRouter(); return ( @@ -52,6 +59,13 @@ export function ActionableProposalsTable({ proposals }: { proposals: UserProposa Declined + {customColumns.map((column) => ( + + + {column.title} + + + ))} Action @@ -98,7 +112,7 @@ export function ActionableProposalsTable({ proposals }: { proposals: UserProposa router.push(`/${router.query.domain}/${proposal.path}`); }} > - + {proposal.title || 'Untitled'} e.stopPropagation()}> @@ -116,7 +130,13 @@ export function ActionableProposalsTable({ proposals }: { proposals: UserProposa - + {proposal.userReviewResult === 'pass' ? ( ) : proposal.userReviewResult === 'fail' ? ( @@ -136,6 +156,7 @@ export function ActionableProposalsTable({ proposals }: { proposals: UserProposa {proposal.totalFailedReviewResults || '-'} + + <> + + + + + Congratulations! + + You claimed {result?.data?.claimedPoints.toLocaleString()} points + Success +
+ +
+
+
+ ); } diff --git a/apps/scoutgame/components/profile/components/PointsClaimScreen/PointsClaimScreen.tsx b/apps/scoutgame/components/profile/components/PointsClaimScreen/PointsClaimScreen.tsx index 85e4d6edd9..5de4032ea3 100644 --- a/apps/scoutgame/components/profile/components/PointsClaimScreen/PointsClaimScreen.tsx +++ b/apps/scoutgame/components/profile/components/PointsClaimScreen/PointsClaimScreen.tsx @@ -1,6 +1,6 @@ 'use server'; -import { Paper, Typography, Stack } from '@mui/material'; +import { Box, Paper, Typography, Stack } from '@mui/material'; import Image from 'next/image'; import { getClaimablePointsWithEvents } from 'lib/points/getClaimablePointsWithEvents'; @@ -14,9 +14,23 @@ export async function PointsClaimScreen({ userId, username }: { userId: string; if (!totalClaimablePoints) { return ( - - No points available to claim. Keep playing and check back next week! - + + + No points available to claim. + + + Keep playing and check back next week! + + ); } diff --git a/apps/scoutgame/components/profile/components/PointsClaimScreen/QualifiedActionsTable.tsx b/apps/scoutgame/components/profile/components/PointsClaimScreen/QualifiedActionsTable.tsx index df44cd9b55..d1135bf1d5 100644 --- a/apps/scoutgame/components/profile/components/PointsClaimScreen/QualifiedActionsTable.tsx +++ b/apps/scoutgame/components/profile/components/PointsClaimScreen/QualifiedActionsTable.tsx @@ -48,11 +48,11 @@ export function QualifiedActionsTable({ weeklyRewards }: { weeklyRewards: Weekly ) : null} - {weeklyReward.builderReward ? ( + {weeklyReward.scoutReward ? ( - Builder Rewards + Scout Rewards - {weeklyReward.builderReward.points} + {weeklyReward.scoutReward.points} Nfts diff --git a/apps/scoutgame/components/profile/components/ProfileTabsMenu.tsx b/apps/scoutgame/components/profile/components/ProfileTabsMenu.tsx index 63902fe6e5..88171f44ea 100644 --- a/apps/scoutgame/components/profile/components/ProfileTabsMenu.tsx +++ b/apps/scoutgame/components/profile/components/ProfileTabsMenu.tsx @@ -6,6 +6,7 @@ import { useRouter } from 'next/navigation'; import { useEffect } from 'react'; import { TabsMenu } from 'components/common/Tabs/TabsMenu'; +import { useGetClaimablePoints } from 'hooks/api/session'; import { useMdScreen } from 'hooks/useMediaScreens'; import type { ProfileTab } from '../ProfilePage'; @@ -14,10 +15,13 @@ const desktopTabs = ['scout-build', 'win']; const mobileTabs = ['scout', 'build', 'win']; export function ProfileTabsMenu({ tab }: { tab: ProfileTab }) { + const { data: claimablePoints } = useGetClaimablePoints(); const isMobile = useMediaQuery((theme: Theme) => theme.breakpoints.down('sm')); const isDesktop = useMdScreen(); const router = useRouter(); + const showWinBadge = claimablePoints && claimablePoints.points > 0; + // Initially both mobile and desktop is set to false, without returning early the UI flickers useEffect(() => { @@ -48,7 +52,7 @@ export function ProfileTabsMenu({ tab }: { tab: ProfileTab }) { value={tab || 'scout-build'} tabs={[ { value: 'scout-build', label: 'Scout. Build.' }, - { value: 'win', label: 'Win' } + { value: 'win', label: 'Win', showBadge: showWinBadge } ]} /> ); @@ -60,7 +64,7 @@ export function ProfileTabsMenu({ tab }: { tab: ProfileTab }) { tabs={[ { value: 'scout', label: 'Scout' }, { value: 'build', label: 'Build' }, - { value: 'win', label: 'Win' } + { value: 'win', label: 'Win', showBadge: showWinBadge } ]} /> ); diff --git a/apps/scoutgame/hooks/api/session.ts b/apps/scoutgame/hooks/api/session.ts index 529ff8461d..fbf25a381f 100644 --- a/apps/scoutgame/hooks/api/session.ts +++ b/apps/scoutgame/hooks/api/session.ts @@ -1,11 +1,19 @@ import type { SessionUser } from 'lib/session/getUserFromSession'; -import { useGETImmutable } from './helpers'; +import { useGETImmutable, useGETtrigger } from './helpers'; -export function useRefreshUser() { +export function useRefreshUserProfiles() { return useGETImmutable<[]>('/api/session/refresh'); } export function useGetUser() { return useGETImmutable('/api/session/user'); } + +export function useGetUserTrigger() { + return useGETtrigger('/api/session/user'); +} + +export function useGetClaimablePoints() { + return useGETImmutable<{ points: number }>('/api/session/claimable-points'); +} diff --git a/apps/scoutgame/lib/points/claimPointsAction.ts b/apps/scoutgame/lib/points/claimPointsAction.ts index ad1244b199..4bfcd16787 100644 --- a/apps/scoutgame/lib/points/claimPointsAction.ts +++ b/apps/scoutgame/lib/points/claimPointsAction.ts @@ -6,7 +6,7 @@ import { revalidatePath } from 'next/cache'; import { authActionClient } from 'lib/actions/actionClient'; export const claimPointsAction = authActionClient.metadata({ actionName: 'claim_points' }).action(async ({ ctx }) => { - await claimPoints({ userId: ctx.session.scoutId }); + const result = await claimPoints({ userId: ctx.session.scoutId }); revalidatePath('/profile'); - return { success: true }; + return { success: true, claimedPoints: result.total }; }); diff --git a/apps/scoutgame/lib/points/getClaimablePointsWithEvents.ts b/apps/scoutgame/lib/points/getClaimablePointsWithEvents.ts index 5925c949bf..d89b3048b1 100644 --- a/apps/scoutgame/lib/points/getClaimablePointsWithEvents.ts +++ b/apps/scoutgame/lib/points/getClaimablePointsWithEvents.ts @@ -4,7 +4,7 @@ import { currentSeason, getPreviousSeason, getSeasonWeekFromISOWeek } from '@pac export type WeeklyReward = { week: string; weekNumber: number; - builderReward: { + scoutReward: { points: number; }; githubContributionReward: { @@ -63,7 +63,7 @@ export async function getClaimablePointsWithEvents( const totalClaimablePoints = pointsReceipts.reduce((acc, receipt) => acc + receipt.value, 0); - const builderRewards: Record = {}; + const scoutRewards: Record = {}; const soldNftRewards: Record = {}; const githubContributionRewards: Record = {}; @@ -155,13 +155,14 @@ export async function getClaimablePointsWithEvents( soldNftReward.quantity += receipt.event.nftPurchaseEvent.tokensPurchased ?? 1; } else if (receipt.event.type === 'gems_payout') { if (receipt.event.builderId !== receipt.recipientId) { - const builderReward = builderRewards[week]; - if (!builderReward) { - builderRewards[week] = { - points: 0 + const scoutReward = scoutRewards[week]; + if (!scoutReward) { + scoutRewards[week] = { + points }; + } else { + scoutReward.points += points; } - builderReward.points += points; } else { const githubContribution = weeklyGithubContributionRecord[week]; if (!githubContributionRewards[week]) { @@ -193,7 +194,7 @@ export async function getClaimablePointsWithEvents( season: allWeeks[week], week }), - builderReward: builderRewards[week], + scoutReward: scoutRewards[week], githubContributionReward: githubContributionRewards[week], soldNftReward: soldNftRewards[week], rank: weeklyRankRecord[week], diff --git a/apps/scoutgame/public/images/trophy_sticker.jpg b/apps/scoutgame/public/images/trophy_sticker.jpg new file mode 100644 index 0000000000000000000000000000000000000000..a3ec90cf6606978206ff724ab7a1edcba61785d9 GIT binary patch literal 67552 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 Qk5kUYY;h1D=-0@<0ee>J00000 literal 0 HcmV?d00001 diff --git a/packages/scoutgame/src/points/claimPoints.ts b/packages/scoutgame/src/points/claimPoints.ts index efd80cdc89..a09cb3f01c 100644 --- a/packages/scoutgame/src/points/claimPoints.ts +++ b/packages/scoutgame/src/points/claimPoints.ts @@ -47,7 +47,7 @@ export async function claimPoints({ season = currentSeason, userId }: { season?: } } - return prisma.$transaction([ + await prisma.$transaction([ prisma.pointsReceipt.updateMany({ where: { id: { @@ -69,4 +69,5 @@ export async function claimPoints({ season = currentSeason, userId }: { season?: } }) ]); + return { total: builderPoints + scoutPoints, builderPoints, scoutPoints }; } From 4c5cb39e9953c288925b1d549d29fbef02ca78cb Mon Sep 17 00:00:00 2001 From: motechFR Date: Sat, 12 Oct 2024 18:26:38 -0500 Subject: [PATCH 14/38] Add testing for the points payout (#4793) * Add testing for the points payout * Remove dead code * Update weekly allocated points value * WIP testing claimablePointsWithEvents * Drop test --------- Co-authored-by: mattcasey --- .../processScoutPointsPayout.spec.ts | 21 +++-- .../processScoutPointsPayout.ts | 26 ++++-- .../scoutgame/src/__tests__/dates.spec.ts | 8 +- .../__tests__/getBuildersLeaderboard.spec.ts | 3 + .../builderNfts/__tests__/constants.spec.ts | 11 +++ .../scoutgame/src/builderNfts/constants.ts | 4 + packages/scoutgame/src/dates.ts | 4 +- .../src/points/__tests__/claimPoints.spec.ts | 7 +- .../scoutgame/src/points/calculatePoints.ts | 91 ------------------- .../__tests__/mockNFTPurchaseEvent.spec.ts | 65 +++++++++++++ packages/scoutgame/src/testing/database.ts | 6 +- 11 files changed, 132 insertions(+), 114 deletions(-) create mode 100644 packages/scoutgame/src/builderNfts/__tests__/constants.spec.ts create mode 100644 packages/scoutgame/src/testing/__tests__/mockNFTPurchaseEvent.spec.ts diff --git a/apps/scoutgamecron/src/tasks/processGemsPayout/__tests__/processScoutPointsPayout.spec.ts b/apps/scoutgamecron/src/tasks/processGemsPayout/__tests__/processScoutPointsPayout.spec.ts index 015758e5c5..2b1b55c409 100644 --- a/apps/scoutgamecron/src/tasks/processGemsPayout/__tests__/processScoutPointsPayout.spec.ts +++ b/apps/scoutgamecron/src/tasks/processGemsPayout/__tests__/processScoutPointsPayout.spec.ts @@ -1,4 +1,5 @@ import { prisma } from '@charmverse/core/prisma-client'; +import { builderPointsShare, scoutPointsShare } from '@packages/scoutgame/builderNfts/constants'; import { getCurrentWeek } from '@packages/scoutgame/dates'; import { calculateEarnableScoutPointsForRank } from '@packages/scoutgame/points/calculatePoints'; import { mockBuilder, mockBuilderNft, mockNFTPurchaseEvent, mockScout } from '@packages/scoutgame/testing/database'; @@ -73,7 +74,7 @@ describe('processScoutPointsPayout', () => { expect(scout2Activities).toBe(0); }); - it('should distribute points correctly among NFT holders and builder', async () => { + it('should distribute points correctly among NFT holders and builder, respecting scout builder splits, and proportionally to NFTs owned', async () => { const builder = await mockBuilder(); const rank = 1; const gemsCollected = 10; @@ -84,17 +85,20 @@ describe('processScoutPointsPayout', () => { const scout1 = await mockScout(); const scout2 = await mockScout(); - // Scout 1 has 2 NFTs, scout 2 has 1 NFT - await mockNFTPurchaseEvent({ builderId: builder.id, scoutId: scout1.id, points: 0 }); + // Scout 1 has 3 NFTs, scout 2 has 7 NFTs + await mockNFTPurchaseEvent({ builderId: builder.id, scoutId: scout1.id, points: 0, tokensPurchased: 2 }); await mockNFTPurchaseEvent({ builderId: builder.id, scoutId: scout1.id, points: 0 }); await mockNFTPurchaseEvent({ builderId: builder.id, scoutId: scout2.id, points: 0 }); + await mockNFTPurchaseEvent({ builderId: builder.id, scoutId: scout2.id, points: 0, tokensPurchased: 6 }); const totalPoints = calculateEarnableScoutPointsForRank(rank); await prisma.pointsReceipt.deleteMany({ where: { - recipientId: builder.id + recipientId: { + in: [builder.id, scout1.id, scout2.id] + } } }); await processScoutPointsPayout({ @@ -112,7 +116,8 @@ describe('processScoutPointsPayout', () => { createdAt: 'desc' } }); - expect(builderPointReceipt.value).toBeCloseTo(Math.floor(0.2 * totalPoints)); + + expect(Math.floor(builderPointReceipt.value)).toEqual(Math.floor(builderPointsShare * totalPoints)); const builderStats = await getStats({ userId: builder.id }); expect(builderStats.season?.pointsEarnedAsBuilder).toBe(builderPointReceipt.value); @@ -124,7 +129,7 @@ describe('processScoutPointsPayout', () => { } }); - expect(scout1PointReceipt.value).toBeCloseTo(Math.floor(0.8 * totalPoints * (2 / 3))); + expect(Math.floor(scout1PointReceipt.value)).toEqual(Math.floor(scoutPointsShare * totalPoints * (3 / 10))); const scout1Stats = await getStats({ userId: scout1.id }); expect(scout1Stats.season?.pointsEarnedAsScout).toBe(scout1PointReceipt.value); @@ -136,7 +141,7 @@ describe('processScoutPointsPayout', () => { } }); - expect(scout2PointReceipt.value).toBeCloseTo(Math.floor(0.8 * totalPoints * (1 / 3))); + expect(Math.floor(scout2PointReceipt.value)).toEqual(Math.floor(scoutPointsShare * totalPoints * (7 / 10))); const scout2Stats = await getStats({ userId: scout2.id }); expect(scout2Stats.season?.pointsEarnedAsScout).toBe(scout2PointReceipt.value); @@ -150,7 +155,7 @@ describe('processScoutPointsPayout', () => { pointsReceiptId: builderPointReceipt.id } }); - expect(builderPointReceipt.value).toBeCloseTo(Math.floor(0.2 * totalPoints)); + expect(Math.floor(builderPointReceipt.value)).toEqual(Math.floor(builderPointsShare * totalPoints)); expect(builderActivities).toBe(1); diff --git a/apps/scoutgamecron/src/tasks/processGemsPayout/processScoutPointsPayout.ts b/apps/scoutgamecron/src/tasks/processGemsPayout/processScoutPointsPayout.ts index 15d38275ee..75bcd7a605 100644 --- a/apps/scoutgamecron/src/tasks/processGemsPayout/processScoutPointsPayout.ts +++ b/apps/scoutgamecron/src/tasks/processGemsPayout/processScoutPointsPayout.ts @@ -1,4 +1,5 @@ import { prisma } from '@charmverse/core/prisma-client'; +import { builderPointsShare, scoutPointsShare } from '@packages/scoutgame/builderNfts/constants'; import { calculateEarnableScoutPointsForRank } from '@packages/scoutgame/points/calculatePoints'; import { updatePointsEarned } from '@packages/scoutgame/points/updatePointsEarned'; import { v4 } from 'uuid'; @@ -18,20 +19,27 @@ export async function processScoutPointsPayout({ season: string; createdAt?: Date; }) { - const nftHolders = await prisma.nFTPurchaseEvent.groupBy({ - by: ['scoutId'], + const nftPurchaseEvents = await prisma.nFTPurchaseEvent.findMany({ where: { builderNFT: { season, builderId } - }, - _count: { - scoutId: true } }); - const totalNftsPurchased = nftHolders.reduce((acc, { _count: { scoutId: count } }) => acc + count, 0); + const { totalNftsPurchased, nftsByScout } = nftPurchaseEvents.reduce( + (acc, purchaseEvent) => { + acc.totalNftsPurchased += purchaseEvent.tokensPurchased; + acc.nftsByScout[purchaseEvent.scoutId] = + (acc.nftsByScout[purchaseEvent.scoutId] || 0) + purchaseEvent.tokensPurchased; + return acc; + }, + { + totalNftsPurchased: 0, + nftsByScout: {} as Record + } + ); if (totalNftsPurchased === 0) { return; @@ -75,10 +83,10 @@ export async function processScoutPointsPayout({ } }); - const builderPoints = Math.floor(0.2 * earnableScoutPoints); + const builderPoints = Math.floor(builderPointsShare * earnableScoutPoints); await Promise.all([ - ...nftHolders.map(async ({ scoutId, _count: { scoutId: nftsPurchased } }) => { - const scoutPoints = Math.floor(0.8 * earnableScoutPoints * (nftsPurchased / totalNftsPurchased)); + ...Object.entries(nftsByScout).map(async ([scoutId, tokensPurchased]) => { + const scoutPoints = Math.floor(scoutPointsShare * earnableScoutPoints * (tokensPurchased / totalNftsPurchased)); await tx.pointsReceipt.create({ data: { value: scoutPoints, diff --git a/packages/scoutgame/src/__tests__/dates.spec.ts b/packages/scoutgame/src/__tests__/dates.spec.ts index d296c80532..5af4e1337a 100644 --- a/packages/scoutgame/src/__tests__/dates.spec.ts +++ b/packages/scoutgame/src/__tests__/dates.spec.ts @@ -1,6 +1,6 @@ import { DateTime } from 'luxon'; -import { getWeekFromDate, getWeekStartEnd, getSeasonWeekFromISOWeek } from '../dates'; +import { getWeekFromDate, getWeekStartEnd, getSeasonWeekFromISOWeek, weeklyAllocatedPoints } from '../dates'; describe('date utils', () => { describe('getWeekFromDate', () => { @@ -53,3 +53,9 @@ describe('date utils', () => { }); }); }); + +describe('constants', () => { + it('weeklyAllocatedPoints should be 100,000 (change this test over time to reflect updated values)', () => { + expect(weeklyAllocatedPoints).toEqual(1e5); + }); +}); diff --git a/packages/scoutgame/src/__tests__/getBuildersLeaderboard.spec.ts b/packages/scoutgame/src/__tests__/getBuildersLeaderboard.spec.ts index 29570f987c..a7c7945541 100644 --- a/packages/scoutgame/src/__tests__/getBuildersLeaderboard.spec.ts +++ b/packages/scoutgame/src/__tests__/getBuildersLeaderboard.spec.ts @@ -170,5 +170,8 @@ describe('getBuildersLeaderboard', () => { const topBuilders = await getBuildersLeaderboard({ quantity: 5, week: testWeek }); expect(topBuilders).toHaveLength(3); + expect(topBuilders.map((b) => b.builder.id)).toEqual( + builders.filter((b) => b.builderStatus === 'approved').map((b) => b.id) + ); }); }); diff --git a/packages/scoutgame/src/builderNfts/__tests__/constants.spec.ts b/packages/scoutgame/src/builderNfts/__tests__/constants.spec.ts new file mode 100644 index 0000000000..85c6b63f9a --- /dev/null +++ b/packages/scoutgame/src/builderNfts/__tests__/constants.spec.ts @@ -0,0 +1,11 @@ +import { builderPointsShare, scoutPointsShare } from '../constants'; + +describe('point splits', () => { + it('scoutPointsShare should have a value of 0.8', () => { + expect(scoutPointsShare).toBe(0.8); + }); + + it('builderPointsShare should have a value of 0.2', () => { + expect(builderPointsShare).toBe(0.2); + }); +}); diff --git a/packages/scoutgame/src/builderNfts/constants.ts b/packages/scoutgame/src/builderNfts/constants.ts index c4701c58f1..3ec4a8823d 100644 --- a/packages/scoutgame/src/builderNfts/constants.ts +++ b/packages/scoutgame/src/builderNfts/constants.ts @@ -46,6 +46,10 @@ export function getDecentApiKey() { const apiKey = env('DECENT_API_KEY') || process.env.REACT_APP_DECENT_API_KEY; return apiKey; } + +export const scoutPointsShare = 0.8; +export const builderPointsShare = 0.2; + // const serverClient = getWalletClient({ chainId: builderNftChain.id, privateKey: builderSmartContractOwnerKey }); // const apiClient = new BuilderNFTSeasonOneClient({ diff --git a/packages/scoutgame/src/dates.ts b/packages/scoutgame/src/dates.ts index da4144790f..a3c717abaa 100644 --- a/packages/scoutgame/src/dates.ts +++ b/packages/scoutgame/src/dates.ts @@ -31,7 +31,9 @@ export const currentSeasonNumber = 1; export const streakWindow = 7 * 24 * 60 * 60 * 1000; export const seasonAllocatedPoints = 18_141_850; -export const weeklyAllocatedPoints = seasonAllocatedPoints / 13; +// Currently, we are hardcoding the value of weekly allocated points to 100,000 +// export const weeklyAllocatedPoints = seasonAllocatedPoints / 13; +export const weeklyAllocatedPoints = 1e5; // Return the format of week export function getCurrentWeek(): ISOWeek { diff --git a/packages/scoutgame/src/points/__tests__/claimPoints.spec.ts b/packages/scoutgame/src/points/__tests__/claimPoints.spec.ts index 070e911c91..05defafcab 100644 --- a/packages/scoutgame/src/points/__tests__/claimPoints.spec.ts +++ b/packages/scoutgame/src/points/__tests__/claimPoints.spec.ts @@ -10,11 +10,13 @@ describe('claimPoints', () => { const scout = await mockScout(); await mockGemPayoutEvent({ builderId: builder.id, - recipientId: builder.id + recipientId: builder.id, + amount: 10 }); await mockNFTPurchaseEvent({ builderId: builder.id, - scoutId: scout.id + scoutId: scout.id, + points: 20 }); await claimPoints({ userId: builder.id, season: currentSeason }); @@ -24,6 +26,7 @@ describe('claimPoints', () => { recipientId: builder.id } }); + expect(transactions).toHaveLength(2); expect(transactions[0].claimedAt).not.toBeNull(); expect(transactions[1].claimedAt).not.toBeNull(); diff --git a/packages/scoutgame/src/points/calculatePoints.ts b/packages/scoutgame/src/points/calculatePoints.ts index b58ce126e0..f06cc26202 100644 --- a/packages/scoutgame/src/points/calculatePoints.ts +++ b/packages/scoutgame/src/points/calculatePoints.ts @@ -1,98 +1,7 @@ -import type { - BuilderNft, - BuilderEvent, - GemsReceipt, - NFTPurchaseEvent, - PointsReceipt -} from '@charmverse/core/prisma-client'; - import { weeklyAllocatedPoints } from '../dates'; -const gemsToPoints = 1; const decayRate = 0.03; -type BuilderNftMeta = Pick; - -type NFTPurchaseEventWithBuilderNftMeta = NFTPurchaseEvent & { builderNft: BuilderNftMeta }; - -// calculate a builder's points based on gems -export function getBuilderPointsFromGems( - builderId: string, - receipts: (GemsReceipt & { event: Pick })[] -) { - const gemsEarned = receipts.reduce((acc, receipt) => { - if (receipt.event.builderId === builderId) { - return acc + receipt.value; - } - return acc; - }, 0); - return gemsEarned * gemsToPoints; -} - -// return the % of points earned by a scout from an NFT -export function getNFTScoutSplit(contract: string, tokenId: number, nftEvents: NFTPurchaseEventWithBuilderNftMeta[]) { - const nfts = nftEvents.filter((nft) => nft.builderNft.contractAddress === contract); - // TODO: apply actual equation - return 100 / nfts.length; -} - -// get the points for a user based on NFTs and gem receipts -export function getPointsFromGems( - userId: string, - nftEvents: NFTPurchaseEventWithBuilderNftMeta[], - receipts: (GemsReceipt & { event: Pick })[] -) { - const nfts = nftEvents.filter((nft) => nft.builderNft.builderId === userId); - const pointsFromNFTs = nfts.reduce((acc, nft) => { - const builderPoints = getBuilderPointsFromGems(nft.builderNft.builderId, receipts); - const scoutSplit = getNFTScoutSplit(nft.builderNft.contractAddress, nft.builderNft.tokenId, nftEvents); - return acc + builderPoints * scoutSplit; - }, 0); - return pointsFromNFTs + getBuilderPointsFromGems(userId, receipts); -} - -// calculate a user's current points balance based on receipts -export function getCurrentPointsBalance(scoutId: string, receipts: PointsReceipt[]) { - return receipts.reduce((acc, receipt) => { - if (receipt.recipientId === scoutId) { - return acc + receipt.value; - } else if (receipt.senderId === scoutId) { - return acc - receipt.value; - } - return acc; - }, 0); -} - -// get points for: merge pull requests -export function getPointsEarnedAsBuilder(builderId: string, receipts: (PointsReceipt & { event: BuilderEvent })[]) { - return receipts.reduce((acc, receipt) => { - if (receipt.recipientId === builderId) { - // receipt from github events by builder - if (receipt.event.type === 'merged_pull_request' && receipt.event.builderId === builderId) { - return acc + receipt.value; - } - } - return acc; - }, 0); -} - -// get points for: merge pull requests from other users -export function getPointsEarnedAsScout(scoutId: string, receipts: (PointsReceipt & { event: BuilderEvent })[]) { - return receipts.reduce((acc, receipt) => { - if (receipt.recipientId === scoutId) { - // receipt from github events by someone else - if (receipt.event.type === 'merged_pull_request' && receipt.event.builderId !== scoutId) { - return acc + receipt.value; - } - // receipt for payout - if (receipt.event.type === 'gems_payout') { - return acc + receipt.value; - } - } - return acc; - }, 0); -} - export function customCalculateEarnableScoutPointsForRank({ rank, points }: { rank: number; points: number }) { return points * ((1 - decayRate) ** (rank - 1) - (1 - decayRate) ** rank); } diff --git a/packages/scoutgame/src/testing/__tests__/mockNFTPurchaseEvent.spec.ts b/packages/scoutgame/src/testing/__tests__/mockNFTPurchaseEvent.spec.ts new file mode 100644 index 0000000000..2c6dbe3bbf --- /dev/null +++ b/packages/scoutgame/src/testing/__tests__/mockNFTPurchaseEvent.spec.ts @@ -0,0 +1,65 @@ +import type { BuilderEvent, NFTPurchaseEvent, PointsReceipt } from '@charmverse/core/prisma-client'; +import { prisma } from '@charmverse/core/prisma-client'; // Assuming prisma is directly imported from your Prisma client instance + +import { mockBuilder, mockNFTPurchaseEvent, mockScout } from '../database'; // Assuming mockBuilderNft is in a separate file + +describe('mockNFTPurchaseEvent', () => { + it('should create an NFT purchase event, passing through the parameters and creating the points receipts', async () => { + // Assuming you have a function to create a mock builderNft + const builder = await mockBuilder(); + const scout = await mockScout(); + const points = 10; + const tokensPurchased = 100; + + const currentSeason = 'current-mock-season'; + + const result = await mockNFTPurchaseEvent({ + builderId: builder.id, + scoutId: scout.id, + points, + season: currentSeason, + tokensPurchased + }); + + // Verify builderEvent and nftPurchaseEvent creation + const createdEvent = await prisma.builderEvent.findFirstOrThrow({ + where: { + nftPurchaseEventId: result.nftPurchaseEventId + }, + include: { nftPurchaseEvent: true } + }); + + expect(createdEvent).toMatchObject( + expect.objectContaining }>>({ + builderId: builder.id, + type: 'nft_purchase', + season: currentSeason, + nftPurchaseEvent: expect.objectContaining({ + pointsValue: points, + tokensPurchased, + scoutId: scout.id + }) + }) + ); + + const pointsReceipts = await prisma.pointsReceipt.findMany({ + where: { + event: { + id: createdEvent.id + } + } + }); + + expect(pointsReceipts).toHaveLength(1); + + expect(pointsReceipts[0]).toMatchObject( + expect.objectContaining>({ + claimedAt: null, + eventId: createdEvent.id, + recipientId: builder.id, + senderId: scout.id, + value: points + }) + ); + }); +}); diff --git a/packages/scoutgame/src/testing/database.ts b/packages/scoutgame/src/testing/database.ts index 0dd01f0cb1..47d5fbb0e5 100644 --- a/packages/scoutgame/src/testing/database.ts +++ b/packages/scoutgame/src/testing/database.ts @@ -191,12 +191,14 @@ export async function mockNFTPurchaseEvent({ builderId, scoutId, points = 0, - season = mockSeason + season = mockSeason, + tokensPurchased = 1 }: { builderId: string; scoutId: string; points?: number; season?: string; + tokensPurchased?: number; }) { let builderNft = await prisma.builderNft.findFirst({ where: { @@ -224,7 +226,7 @@ export async function mockNFTPurchaseEvent({ scoutId, pointsValue: points, txHash: `0x${Math.random().toString(16).substring(2)}`, - tokensPurchased: 1 + tokensPurchased } }, pointsReceipts: { From 10f2c7b2c30545b35b6034970e9cb9260a64e11f Mon Sep 17 00:00:00 2001 From: mattcasey Date: Sun, 13 Oct 2024 08:24:44 -0500 Subject: [PATCH 15/38] reenable cron --- apps/scoutgamecron/cron.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/scoutgamecron/cron.yml b/apps/scoutgamecron/cron.yml index a27a48fc35..a2723a3ff9 100644 --- a/apps/scoutgamecron/cron.yml +++ b/apps/scoutgamecron/cron.yml @@ -5,10 +5,10 @@ cron: # every 20 minutes schedule: "*/20 * * * *" - # - name: "process-gems-payout" - # url: "/process-gems-payout" - # # Start of every hour - # schedule: "0 * * * *" + - name: "process-gems-payout" + url: "/process-gems-payout" + # Start of every hour + schedule: "0 * * * *" - name: "process-nft-mints" url: "/process-nft-mints" From e55dc8ff5028b6c3ff93c0d94b5bbac227c56ae2 Mon Sep 17 00:00:00 2001 From: Matt Casey Date: Sun, 13 Oct 2024 08:26:37 -0500 Subject: [PATCH 16/38] Scout game admin (#4778) * Revert "remove Coming Soon app (#4698)" This reverts commit 5732f7dea65757f3f1008134531a9e619fe0b9f1. * rename coming soon to admin app * add search and sort * fix search * add indicator * add clear button * show if deleted * . * . * . * update gitignore * reset packages * add login page and require login * update build * hide navigation link * show loading icon * clear swr cache * add logo * fix build * fxi browser tests --- .cdk/config.ts | 5 + .../scoutgameadmin/00_env_vars.config | 14 + .../scoutgameadmin/01_filesystem.config | 4 + .../scoutgameadmin/02_autoscaling.config | 7 + .../03_env_health_ignore_400.config | 13 + .../scoutgameadmin/06_cloudwatch_alarm.config | 21 + .../scoutgameadmin/07_http-to-https.config | 42 ++ .ebstalk.apps.env/scoutgameadmin.env | 9 + .eslintrc.json | 4 +- .github/workflows/deploy_scoutgameadmin.yml | 334 ++++++++++++++ .github/workflows/destroy_staging.yml | 10 +- @connect-shared/lib/actions/actionClient.ts | 6 +- @connect-shared/lib/session/getSession.ts | 4 +- apps/scoutgame/scripts/query.ts | 9 + apps/scoutgameadmin/Dockerfile | 36 ++ apps/scoutgameadmin/README.md | 1 + apps/scoutgameadmin/app/(general)/layout.tsx | 24 + .../app/(general)/repos/page.tsx | 9 + .../scoutgameadmin/app/(general)/template.tsx | 6 + apps/scoutgameadmin/app/(info)/layout.tsx | 18 + apps/scoutgameadmin/app/(info)/login/page.tsx | 11 + apps/scoutgameadmin/app/(info)/template.tsx | 6 + .../app/api/github/search-repos/route.ts | 41 ++ apps/scoutgameadmin/app/api/health/route.ts | 3 + .../app/api/repos/export/route.ts | 20 + apps/scoutgameadmin/app/api/repos/route.ts | 21 + .../app/api/users/export/route.ts | 50 ++ apps/scoutgameadmin/app/layout.tsx | 53 +++ apps/scoutgameadmin/app/manifest.ts | 34 ++ apps/scoutgameadmin/app/not-found.tsx | 5 + apps/scoutgameadmin/app/page.tsx | 5 + apps/scoutgameadmin/app/robots.ts | 10 + apps/scoutgameadmin/app/template.tsx | 6 + .../components/common/Header.tsx | 101 +++++ .../components/common/Hidden.tsx | 27 ++ .../components/common/SiteNavigation.tsx | 74 +++ .../components/common/StickyFooter.tsx | 14 + .../components/login/LoginPage.tsx | 32 ++ .../login/components/InfoBackgroundImage.tsx | 25 + .../login/components/SinglePageLayout.tsx | 22 + .../WarpcastLogin/FarcasterModal.tsx | 44 ++ .../components/WarpcastLogin/WarpcastIcon.tsx | 19 + .../WarpcastLogin/WarpcastLogin.tsx | 14 + .../WarpcastLogin/WarpcastLoginButton.tsx | 109 +++++ .../repos/AddRepoButton/AddRepoButton.tsx | 25 + .../repos/AddRepoButton/AddRepoModal.tsx | 118 +++++ .../components/repos/ExportButton.tsx | 35 ++ .../components/repos/ReposDashboard.tsx | 191 ++++++++ apps/scoutgameadmin/hooks/api/helpers.ts | 64 +++ apps/scoutgameadmin/hooks/api/repos.ts | 17 + .../scoutgameadmin/hooks/useDebouncedValue.ts | 15 + apps/scoutgameadmin/hooks/useMediaScreens.ts | 16 + .../lib/actions/actionClient.ts | 34 ++ apps/scoutgameadmin/lib/repos/getRepos.ts | 66 +++ .../lib/session/getAdminUser.ts | 15 + apps/scoutgameadmin/lib/session/getSession.ts | 7 + .../lib/session/getUserFromSession.ts | 30 ++ apps/scoutgameadmin/lib/session/interfaces.ts | 11 + .../lib/session/loginWithFarcasterAction.ts | 25 + .../scoutgameadmin/lib/session/saveSession.ts | 8 + apps/scoutgameadmin/middleware.ts | 40 ++ apps/scoutgameadmin/next-env.d.ts | 5 + apps/scoutgameadmin/next.config.mjs | 44 ++ apps/scoutgameadmin/package.json | 15 + apps/scoutgameadmin/playwright.config.ts | 110 +++++ .../public/.well-known/walletconnect.txt | 1 + apps/scoutgameadmin/public/__ENV.js | 1 + apps/scoutgameadmin/public/favicon.ico | Bin 0 -> 15406 bytes .../images/desktop_login_background.png | Bin 0 -> 462441 bytes .../public/images/farcaster.png | Bin 0 -> 76074 bytes apps/scoutgameadmin/public/images/favicon.png | Bin 0 -> 20701 bytes .../public/images/geral_waving.png | Bin 0 -> 97905 bytes .../public/images/lightning_bolt.svg | 15 + .../images/manifest/scoutgame-logo-192.png | Bin 0 -> 10912 bytes .../images/manifest/scoutgame-logo-256.png | Bin 0 -> 16664 bytes .../images/manifest/scoutgame-logo-512.png | Bin 0 -> 33226 bytes .../public/images/mobile_login_background.png | Bin 0 -> 129037 bytes .../public/images/scout-game-logo-square.png | Bin 0 -> 53757 bytes .../public/images/scout-game-logo.png | Bin 0 -> 81388 bytes apps/scoutgameadmin/theme/colors.ts | 36 ++ apps/scoutgameadmin/theme/index.d.ts | 20 + apps/scoutgameadmin/theme/styles.scss | 44 ++ apps/scoutgameadmin/theme/theme.tsx | 312 +++++++++++++ apps/scoutgameadmin/tsconfig.json | 60 +++ .../__snapshots__/centerPanel.spec.tsx.snap | 20 - .../__snapshots__/viewHeader.spec.tsx.snap | 5 - docker-compose.yml | 20 + package-lock.json | 429 +++++++++++++++++- package.json | 7 +- packages/farcaster/src/config.ts | 21 + .../src/hooks/useFarcasterConnection.ts | 38 ++ packages/farcaster/src/verifyFarcasterUser.ts | 31 ++ packages/github/src/getReposByOwner.ts | 12 +- packages/scoutgame/src/scripts/query.ts | 6 +- 94 files changed, 3244 insertions(+), 47 deletions(-) create mode 100644 .ebextensions/scoutgameadmin/00_env_vars.config create mode 100644 .ebextensions/scoutgameadmin/01_filesystem.config create mode 100644 .ebextensions/scoutgameadmin/02_autoscaling.config create mode 100644 .ebextensions/scoutgameadmin/03_env_health_ignore_400.config create mode 100644 .ebextensions/scoutgameadmin/06_cloudwatch_alarm.config create mode 100644 .ebextensions/scoutgameadmin/07_http-to-https.config create mode 100644 .ebstalk.apps.env/scoutgameadmin.env create mode 100644 .github/workflows/deploy_scoutgameadmin.yml create mode 100644 apps/scoutgame/scripts/query.ts create mode 100644 apps/scoutgameadmin/Dockerfile create mode 100644 apps/scoutgameadmin/README.md create mode 100644 apps/scoutgameadmin/app/(general)/layout.tsx create mode 100644 apps/scoutgameadmin/app/(general)/repos/page.tsx create mode 100644 apps/scoutgameadmin/app/(general)/template.tsx create mode 100644 apps/scoutgameadmin/app/(info)/layout.tsx create mode 100644 apps/scoutgameadmin/app/(info)/login/page.tsx create mode 100644 apps/scoutgameadmin/app/(info)/template.tsx create mode 100644 apps/scoutgameadmin/app/api/github/search-repos/route.ts create mode 100644 apps/scoutgameadmin/app/api/health/route.ts create mode 100644 apps/scoutgameadmin/app/api/repos/export/route.ts create mode 100644 apps/scoutgameadmin/app/api/repos/route.ts create mode 100644 apps/scoutgameadmin/app/api/users/export/route.ts create mode 100644 apps/scoutgameadmin/app/layout.tsx create mode 100644 apps/scoutgameadmin/app/manifest.ts create mode 100644 apps/scoutgameadmin/app/not-found.tsx create mode 100644 apps/scoutgameadmin/app/page.tsx create mode 100644 apps/scoutgameadmin/app/robots.ts create mode 100644 apps/scoutgameadmin/app/template.tsx create mode 100644 apps/scoutgameadmin/components/common/Header.tsx create mode 100644 apps/scoutgameadmin/components/common/Hidden.tsx create mode 100644 apps/scoutgameadmin/components/common/SiteNavigation.tsx create mode 100644 apps/scoutgameadmin/components/common/StickyFooter.tsx create mode 100644 apps/scoutgameadmin/components/login/LoginPage.tsx create mode 100644 apps/scoutgameadmin/components/login/components/InfoBackgroundImage.tsx create mode 100644 apps/scoutgameadmin/components/login/components/SinglePageLayout.tsx create mode 100644 apps/scoutgameadmin/components/login/components/WarpcastLogin/FarcasterModal.tsx create mode 100644 apps/scoutgameadmin/components/login/components/WarpcastLogin/WarpcastIcon.tsx create mode 100644 apps/scoutgameadmin/components/login/components/WarpcastLogin/WarpcastLogin.tsx create mode 100644 apps/scoutgameadmin/components/login/components/WarpcastLogin/WarpcastLoginButton.tsx create mode 100644 apps/scoutgameadmin/components/repos/AddRepoButton/AddRepoButton.tsx create mode 100644 apps/scoutgameadmin/components/repos/AddRepoButton/AddRepoModal.tsx create mode 100644 apps/scoutgameadmin/components/repos/ExportButton.tsx create mode 100644 apps/scoutgameadmin/components/repos/ReposDashboard.tsx create mode 100644 apps/scoutgameadmin/hooks/api/helpers.ts create mode 100644 apps/scoutgameadmin/hooks/api/repos.ts create mode 100644 apps/scoutgameadmin/hooks/useDebouncedValue.ts create mode 100644 apps/scoutgameadmin/hooks/useMediaScreens.ts create mode 100644 apps/scoutgameadmin/lib/actions/actionClient.ts create mode 100644 apps/scoutgameadmin/lib/repos/getRepos.ts create mode 100644 apps/scoutgameadmin/lib/session/getAdminUser.ts create mode 100644 apps/scoutgameadmin/lib/session/getSession.ts create mode 100644 apps/scoutgameadmin/lib/session/getUserFromSession.ts create mode 100644 apps/scoutgameadmin/lib/session/interfaces.ts create mode 100644 apps/scoutgameadmin/lib/session/loginWithFarcasterAction.ts create mode 100644 apps/scoutgameadmin/lib/session/saveSession.ts create mode 100644 apps/scoutgameadmin/middleware.ts create mode 100644 apps/scoutgameadmin/next-env.d.ts create mode 100644 apps/scoutgameadmin/next.config.mjs create mode 100644 apps/scoutgameadmin/package.json create mode 100644 apps/scoutgameadmin/playwright.config.ts create mode 100644 apps/scoutgameadmin/public/.well-known/walletconnect.txt create mode 100644 apps/scoutgameadmin/public/__ENV.js create mode 100644 apps/scoutgameadmin/public/favicon.ico create mode 100644 apps/scoutgameadmin/public/images/desktop_login_background.png create mode 100644 apps/scoutgameadmin/public/images/farcaster.png create mode 100644 apps/scoutgameadmin/public/images/favicon.png create mode 100644 apps/scoutgameadmin/public/images/geral_waving.png create mode 100644 apps/scoutgameadmin/public/images/lightning_bolt.svg create mode 100644 apps/scoutgameadmin/public/images/manifest/scoutgame-logo-192.png create mode 100644 apps/scoutgameadmin/public/images/manifest/scoutgame-logo-256.png create mode 100644 apps/scoutgameadmin/public/images/manifest/scoutgame-logo-512.png create mode 100644 apps/scoutgameadmin/public/images/mobile_login_background.png create mode 100644 apps/scoutgameadmin/public/images/scout-game-logo-square.png create mode 100644 apps/scoutgameadmin/public/images/scout-game-logo.png create mode 100644 apps/scoutgameadmin/theme/colors.ts create mode 100644 apps/scoutgameadmin/theme/index.d.ts create mode 100644 apps/scoutgameadmin/theme/styles.scss create mode 100644 apps/scoutgameadmin/theme/theme.tsx create mode 100644 apps/scoutgameadmin/tsconfig.json create mode 100644 packages/farcaster/src/config.ts create mode 100644 packages/farcaster/src/hooks/useFarcasterConnection.ts create mode 100644 packages/farcaster/src/verifyFarcasterUser.ts diff --git a/.cdk/config.ts b/.cdk/config.ts index 08672bd730..e1d67f8da9 100644 --- a/.cdk/config.ts +++ b/.cdk/config.ts @@ -41,6 +41,11 @@ export const apps: { [key: string]: { stg?: Options; prd?: Options } } = { sslCert: sunnyCert } }, + scoutgameadmin: { + prd: { + sslCert: scoutgameCert + } + }, scoutgame: { prd: { sslCert: scoutgameCert diff --git a/.ebextensions/scoutgameadmin/00_env_vars.config b/.ebextensions/scoutgameadmin/00_env_vars.config new file mode 100644 index 0000000000..c19a70f988 --- /dev/null +++ b/.ebextensions/scoutgameadmin/00_env_vars.config @@ -0,0 +1,14 @@ +# Do not put any env var or secrets used by the app here. Put in .ebstalk.apps.env/scoutgame.env +# This file should only have +# - env variables referenced in docker-compose file +# - variables needed for the .platform/hooks/predeploy/01_pull_secrets.sh script to run + +option_settings: + aws:elasticbeanstalk:application:environment: + COMPOSE_PROJECT_NAME: "prd" + COMPOSE_PROFILES: "prd-scoutgameadmin" + EBSTALK_ENV_FILE: "scoutgameadmin.env" + SERVICE_ENV: "prd" # this sets the value of datadog env tag + SERVICE_NAME: "scoutgameadmin" + IMGNAME: "scoutgameadmin" + IMGTAG: "" diff --git a/.ebextensions/scoutgameadmin/01_filesystem.config b/.ebextensions/scoutgameadmin/01_filesystem.config new file mode 100644 index 0000000000..4949bef643 --- /dev/null +++ b/.ebextensions/scoutgameadmin/01_filesystem.config @@ -0,0 +1,4 @@ +option_settings: + aws:autoscaling:launchconfiguration: + RootVolumeType: gp2 + RootVolumeSize: "24" diff --git a/.ebextensions/scoutgameadmin/02_autoscaling.config b/.ebextensions/scoutgameadmin/02_autoscaling.config new file mode 100644 index 0000000000..426d0643f5 --- /dev/null +++ b/.ebextensions/scoutgameadmin/02_autoscaling.config @@ -0,0 +1,7 @@ +# this configures Beanstalk to restart the app if the app's health check fails +Resources: + AWSEBAutoScalingGroup: + Type: "AWS::AutoScaling::AutoScalingGroup" + Properties: + HealthCheckType: ELB + HealthCheckGracePeriod: 300 \ No newline at end of file diff --git a/.ebextensions/scoutgameadmin/03_env_health_ignore_400.config b/.ebextensions/scoutgameadmin/03_env_health_ignore_400.config new file mode 100644 index 0000000000..3bca1866a8 --- /dev/null +++ b/.ebextensions/scoutgameadmin/03_env_health_ignore_400.config @@ -0,0 +1,13 @@ +option_settings: + aws:elasticbeanstalk:healthreporting:system: + SystemType: enhanced + ConfigDocument: + Rules: + Environment: + Application: + ApplicationRequests4xx: + Enabled: false + ELB: + ELBRequests4xx: + Enabled: false + Version: 1 \ No newline at end of file diff --git a/.ebextensions/scoutgameadmin/06_cloudwatch_alarm.config b/.ebextensions/scoutgameadmin/06_cloudwatch_alarm.config new file mode 100644 index 0000000000..a8a41b011f --- /dev/null +++ b/.ebextensions/scoutgameadmin/06_cloudwatch_alarm.config @@ -0,0 +1,21 @@ +# Adding alarm for degraded state +Resources: + EnvHealthAlarm: + Type: "AWS::CloudWatch::Alarm" + Properties: + AlarmDescription: "A CloudWatch Alarm that triggers when an Elastic Beanstalk Environment is unhealthy." + Namespace: "AWS/ElasticBeanstalk" + MetricName: "EnvironmentHealth" + Dimensions: + - Name: EnvironmentName + Value: { "Ref" : "AWSEBEnvironmentName" } + Statistic: "Average" + Period: "300" + EvaluationPeriods: "2" + Threshold: "19" # a value between 15 and 20. 15 is warning, 20 is degraded + ComparisonOperator: "GreaterThanOrEqualToThreshold" + AlarmActions: + - "arn:aws:sns:us-east-1:310849459438:Production-Alerts" + OKActions: + - "arn:aws:sns:us-east-1:310849459438:Production-Alerts" + TreatMissingData: "notBreaching" \ No newline at end of file diff --git a/.ebextensions/scoutgameadmin/07_http-to-https.config b/.ebextensions/scoutgameadmin/07_http-to-https.config new file mode 100644 index 0000000000..97a78c9c3d --- /dev/null +++ b/.ebextensions/scoutgameadmin/07_http-to-https.config @@ -0,0 +1,42 @@ +# source: https://docs.aws.amazon.com/elasticbeanstalk/latest/dg/configuring-https-httpredirect.html + +################################################################################################### +#### Copyright 2016 Amazon.com, Inc. or its affiliates. All Rights Reserved. +#### +#### Licensed under the Apache License, Version 2.0 (the "License"). You may not use this file +#### except in compliance with the License. A copy of the License is located at +#### +#### http://aws.amazon.com/apache2.0/ +#### +#### or in the "license" file accompanying this file. This file is distributed on an "AS IS" +#### BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +#### License for the specific language governing permissions and limitations under the License. +################################################################################################### + +################################################################################################### +#### This configuration file modifies the default port 80 listener attached to an Application Load Balancer +#### to automatically redirect incoming connections on HTTP to HTTPS. +#### This will not work with an environment using the load balancer type Classic or Network. +#### A prerequisite is that the 443 listener has already been created. +#### Please use the below link for more information about creating an Application Load Balancer on +#### the Elastic Beanstalk console. +#### https://docs.aws.amazon.com/elasticbeanstalk/latest/dg/environments-cfg-alb.html#environments-cfg-alb-console +################################################################################################### + +Resources: + AWSEBV2LoadBalancerListener: + Type: AWS::ElasticLoadBalancingV2::Listener + Properties: + LoadBalancerArn: + Ref: AWSEBV2LoadBalancer + Port: 80 + Protocol: HTTP + DefaultActions: + - Type: redirect + RedirectConfig: + Host: "#{host}" + Path: "/#{path}" + Port: "443" + Protocol: "HTTPS" + Query: "#{query}" + StatusCode: "HTTP_301" \ No newline at end of file diff --git a/.ebstalk.apps.env/scoutgameadmin.env b/.ebstalk.apps.env/scoutgameadmin.env new file mode 100644 index 0000000000..c7132dd9dc --- /dev/null +++ b/.ebstalk.apps.env/scoutgameadmin.env @@ -0,0 +1,9 @@ +DOMAIN="https://admin.scoutgame.xyz" +REACT_APP_APP_ENV="production" +NODE_ENV="production" +DATABASE_URL="{{pull:secretsmanager:/io.cv.app/prd/db:SecretString:database_url}}" +AUTH_COOKIE="scoutadmin-session" +AUTH_SECRET="{{pull:secretsmanager:/io.cv.app/prd/auth_secret:SecretString:auth_secret}}" +# apis +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}}" \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json index 17b3bfc1a9..e005a5844e 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -82,6 +82,7 @@ "./apps/cron/", "./apps/farcaster/", "./apps/scoutgame/", + "./apps/scoutgameadmin/", "./apps/scoutgamecron/", "./apps/sunnyawards/", "./apps/waitlist/", @@ -89,7 +90,8 @@ "./apps/ceramic/", "./packages/charmeditor/", "./packages/github/", - "./packages/scoutgame/" + "./packages/scoutgame/", + "./packages/utils/" ] } ], diff --git a/.github/workflows/deploy_scoutgameadmin.yml b/.github/workflows/deploy_scoutgameadmin.yml new file mode 100644 index 0000000000..34f53dbcfb --- /dev/null +++ b/.github/workflows/deploy_scoutgameadmin.yml @@ -0,0 +1,334 @@ +name: Scoutgame Scout Game Admin CI + +on: + push: + branches: [main] + paths: + - '.cdk/**' + - 'docker-compose.yml' + - '.ebextensions/scoutgameadmin/**' + - '.ebstalk.apps.env/scoutgameadmin.env' + - '.github/actions/**' + - '.github/workflows/deploy_scoutgameadmin.yml' + - 'package.json' + - 'package-lock.json' + - 'apps/scoutgameadmin/**' + pull_request: + types: [labeled, opened, synchronize] + branches: ['**'] + paths: + - '.cdk/**' + - 'docker-compose.yml' + - '.ebextensions/scoutgameadmin/**' + - '.ebstalk.apps.env/scoutgameadmin.env' + - '.github/actions/**' + - '.github/workflows/deploy_scoutgameadmin.yml' + - 'package.json' + - 'package-lock.json' + - 'apps/scoutgameadmin/**' + workflow_dispatch: + inputs: + core_pkg_version: + description: 'Core pkg version to update to' + required: true + +concurrency: + group: ci-scoutgameadmin-${{ github.event_name }}-${{ github.ref }} + +jobs: + build: + name: Build Scout Game Admin + runs-on: ubuntu-latest + outputs: + head_commit_message: ${{ steps.setup_variables.outputs.commit_message }} + deploy_staging: ${{ steps.setup_variables.outputs.deploy_staging }} + skip_tests: ${{ steps.setup_variables.outputs.skip_tests }} + steps: + - name: Print Triggering event context payload + env: + workflow_event_context: ${{ toJSON(github.event) }} + run: | + echo "$workflow_event_context" + echo "Workflow and code ref: ${{github.ref}}" + + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup variables + id: setup_variables + # source https://github.com/orgs/community/discussions/28474 + run: | + echo "commit_message=$(git show -s --format=%s)" >> "$GITHUB_OUTPUT" + echo "deploy_staging=${{(github.event.action == 'labeled' && github.event.label.name == ':rocket: deploy-scoutgameadmin') || (github.event.action != 'labeled' && contains(github.event.pull_request.labels.*.name, ':rocket: deploy-scoutgameadmin'))}}" >> $GITHUB_OUTPUT + echo "skip_tests=${{ contains(env.commit_message, 'skip-tests') }}" >> $GITHUB_OUTPUT + + - name: Install dependencies + uses: ./.github/actions/install + with: + core_pkg_version: ${{ inputs.core_pkg_version }} + commit_core_pkg_upgrade: true + + - name: Build app + uses: ./.github/actions/build_app + with: + app_name: scoutgameadmin + + test-scoutgameadmin: + name: Test apps + runs-on: ubuntu-latest + needs: build + if: ${{ github.event.action != 'labeled' && needs.build.outputs.skip_tests != 'true' }} + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Restore dependencies from cache + uses: ./.github/actions/install + + - name: Restore app from cache + uses: ./.github/actions/build_app + with: + app_name: scoutgameadmin + + - name: Typecheck app + run: npm run typecheck -w apps/scoutgameadmin + + upload-docker: + name: Upload Docker image + runs-on: ubuntu-latest + # run whether previous jobs were successful or skipped + if: | + github.ref == 'refs/heads/main' || needs.build.outputs.deploy_staging == 'true' + needs: build + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install dependencies + uses: ./.github/actions/install + + - name: Restore app from cache + uses: ./.github/actions/build_app + with: + app_name: scoutgameadmin + + - name: Update Dockerfile + run: | + rm Dockerfile && mv apps/scoutgameadmin/Dockerfile Dockerfile + + - name: Build and Push Docker image + id: docker_build_push + uses: ./.github/actions/build_docker_image + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_REGION: us-east-1 + with: + ecr_registry: scoutgameadmin + + upload-static-assets: + name: Upload assets in production + runs-on: ubuntu-latest + # run whether previous jobs were successful or skipped + if: github.ref == 'refs/heads/main' && !(failure() || cancelled()) + needs: build + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install dependencies + uses: ./.github/actions/install + with: + app_name: scoutgameadmin + + - name: Calculate Build ID + id: get_build_id + run: | + build_id=${{ hashFiles('package-lock.json', 'apps/**/*.[jt]s', 'lib/**/*.[jt]s') }} + echo "build_id=$build_id" >> $GITHUB_OUTPUT + + - name: Restore app from cache + uses: ./.github/actions/build_app + with: + app_name: scoutgameadmin + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-1 + + deploy-production: + name: Deploy to production + # run whether previous jobs were successful or skipped + if: github.ref == 'refs/heads/main' && !(failure() || cancelled()) + needs: [test-scoutgameadmin, upload-docker, upload-static-assets] + runs-on: ubuntu-latest + strategy: + matrix: + include: + - stack: prd-scoutgameadmin + ebextensions: scoutgameadmin + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Inject slug/short variables + uses: rlespinasse/github-slug-action@v4.x + with: + short-length: 7 + + # we need to bring back node_modules which includes tsconfig-paths which is used by CDK files + - name: Install dependencies + uses: ./.github/actions/install + with: + app_name: scoutgameadmin + + - name: Set the docker compose env variables + uses: mikefarah/yq@master + with: + cmd: | + mv .ebextensions .ebextensions_tmp && mv .ebextensions_tmp/${{ matrix.ebextensions }} .ebextensions + yq -I 4 -i ' + with(.option_settings."aws:elasticbeanstalk:application:environment"; + .IMGTAG = "${{ github.run_id }}-${{ env.GITHUB_SHA_SHORT }}") + ' .ebextensions/00_env_vars.config + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-1 + + - name: Package and deploy + run: | + cat files_to_zip.txt | zip --symlinks -r@ ${{ matrix.stack }}.zip + npx aws-cdk deploy --method=direct -c name=${{ matrix.stack }} + + deploy-staging: + name: Deploy to staging + if: needs.build.outputs.deploy_staging == 'true' + + runs-on: ubuntu-latest + # prevent staging deploys + cleanup running in parallel + concurrency: staging-${{ github.ref }} + needs: [build, upload-docker] + steps: + - uses: actions/checkout@v4 + + - name: Inject slug/short variables + uses: rlespinasse/github-slug-action@v4.x + with: + short-length: 7 + + - name: Calculate Stage env var + run: | + full_stage_name="stg-scoutgameadmin-${{ github.event.number }}-${{ env.GITHUB_HEAD_REF_SLUG }}" + + # sanitize and trim string so that it can be used as a valid subdomain. Includes removing hyphens at the start and end of the name + stage_name=`echo "$full_stage_name" | sed -E -e 's/[^a-zA-Z0-9-]+//g' -e 's/(.{40}).*/\1/' -e 's/^-/0/' -e 's/-$/0/'` + + # export the stage name so that it can be used in other steps + echo "STAGE_NAME=$stage_name" >> $GITHUB_ENV + + # we need to bring back node_modules which includes tsconfig-paths which is used by CDK files + - name: Install dependencies + uses: ./.github/actions/install + with: + app_name: scoutgameadmin + + - name: Replace env_var with staging settings + run: | + ebextension_files=$(ls .ebextensions/*/00_env_vars.config) + ebstalk_apps_env_files=$(ls .ebstalk.apps.env/*) + + for conf_file in $ebextension_files $ebstalk_apps_env_files; do + sed -i 's/prd/stg/g' $conf_file + sed -i 's/production/staging/g' $conf_file + done + + # modifying cloudformation alarm to send alerts to test sns topic. + # leaving it in even if we're deleting the config before deploying + # Useful to avoid accidental triggering to system-status channel. + for conf_file in .ebextensions/*/06_cloudwatch_alarm.config; do + sed -i 's/Production-Alerts/lambda-test-debug/g' $conf_file + done + + rm .ebextensions/*/06_cloudwatch_alarm.config + + - name: Create a github deployment + uses: bobheadxi/deployments@v1 + id: deployment + with: + step: start + token: ${{ secrets.GITHUB_TOKEN }} + env: ${{ env.STAGE_NAME }} + ref: ${{ github.head_ref }} + override: true + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-1 + + - name: Set the docker compose env variables + uses: mikefarah/yq@master + with: + cmd: | + mv .ebextensions .ebextensions_tmp && mv .ebextensions_tmp/scoutgameadmin .ebextensions + yq -I 4 -i ' + with(.option_settings."aws:elasticbeanstalk:application:environment"; + .COMPOSE_PROJECT_NAME = "pr${{ github.event.number }}" | + .IMGTAG = "${{ github.run_id }}-${{ env.GITHUB_SHA_SHORT }}") + ' .ebextensions/00_env_vars.config + + - name: Deploy to staging + id: cdk_deploy + run: | + cat files_to_zip.txt | zip --symlinks -r@ ${{env.STAGE_NAME}}.zip + npx aws-cdk deploy -c name=${{env.STAGE_NAME}} --method=direct --outputs-file cdk.out.json + env_url=$(jq --raw-output '.[$ENV.STAGE_NAME].DeploymentUrl' ./cdk.out.json) + echo "env_url=$env_url" >> $GITHUB_OUTPUT + + - name: update the github deployment status + uses: bobheadxi/deployments@v1 + if: always() + with: + env: ${{ steps.deployment.outputs.env }} + step: finish + override: false + token: ${{ secrets.GITHUB_TOKEN }} + status: ${{ job.status }} + deployment_id: ${{ steps.deployment.outputs.deployment_id }} + env_url: ${{ steps.cdk_deploy.outputs.env_url }} + + discord-alert: + name: Notify Discord of failure + runs-on: ubuntu-latest + if: github.ref == 'refs/heads/main' && failure() + # pass in all steps so we can check if any failed + needs: [test-scoutgameadmin, upload-docker, upload-static-assets, upload-docker, deploy-production] + steps: + - name: If any of prev jobs failed notify discord + if: contains(needs.*.result, 'failure') + uses: sarisia/actions-status-discord@v1 + with: + webhook: ${{ secrets.DISCORD_WARNINGS_WEBHOOK }} + status: 'failure' + content: 'Hey <@&1027309276454207519>' + title: 'Scout Game Admin deploy workflow failed' + description: | + Failed workflow URL: https://github.com/charmverse/app.charmverse.io/actions/runs/${{ github.run_id }} + color: '16515843' + url: 'https://github.com/charmverse/app.charmverse.io/actions/runs/${{ github.run_id }}' + username: GitHub Actions + avatar_url: 'https://github.githubassets.com/images/modules/logos_page/Octocat.png' diff --git a/.github/workflows/destroy_staging.yml b/.github/workflows/destroy_staging.yml index 37b282e631..3e7d1eb9d4 100644 --- a/.github/workflows/destroy_staging.yml +++ b/.github/workflows/destroy_staging.yml @@ -11,7 +11,7 @@ jobs: clean-up: if: | (github.event.action == 'unlabeled' && startsWith(github.event.label.name, ':rocket: deploy')) || - (github.event.action == 'closed' && (contains(github.event.pull_request.labels.*.name, ':rocket: deploy') || contains(github.event.pull_request.labels.*.name, ':rocket: deploy-ceramic') || contains(github.event.pull_request.labels.*.name, ':rocket: deploy-scoutgame') || contains(github.event.pull_request.labels.*.name, ':rocket: deploy-sunnyawards') || contains(github.event.pull_request.labels.*.name, ':rocket: deploy-farcaster') || contains(github.event.pull_request.labels.*.name, ':rocket: deploy-cron') || contains(github.event.pull_request.labels.*.name, ':rocket: deploy-waitlist') || contains(github.event.pull_request.labels.*.name, ':rocket: deploy-websockets') || contains(github.event.pull_request.labels.*.name, ':rocket: deploy-scoutgamecron'))) + (github.event.action == 'closed' && (contains(github.event.pull_request.labels.*.name, ':rocket: deploy') || contains(github.event.pull_request.labels.*.name, ':rocket: deploy-ceramic') || contains(github.event.pull_request.labels.*.name, ':rocket: deploy-scoutgame') || contains(github.event.pull_request.labels.*.name, ':rocket: deploy-sunnyawards') || contains(github.event.pull_request.labels.*.name, ':rocket: deploy-scoutgameadmin') || contains(github.event.pull_request.labels.*.name, ':rocket: deploy-farcaster') || contains(github.event.pull_request.labels.*.name, ':rocket: deploy-cron') || contains(github.event.pull_request.labels.*.name, ':rocket: deploy-waitlist') || contains(github.event.pull_request.labels.*.name, ':rocket: deploy-websockets') || contains(github.event.pull_request.labels.*.name, ':rocket: deploy-scoutgamecron'))) runs-on: ubuntu-latest steps: - name: Configure AWS credentials @@ -47,7 +47,7 @@ jobs: run: | stage_name_suffix="${{ github.event.number }}-${{ env.GITHUB_HEAD_REF_SLUG }}" - for app in ceramic cron farcaster scoutgame scoutgamecron sunnyawards waitlist webapp websockets; do + for app in ceramic scoutgameadmin cron farcaster scoutgame scoutgamecron sunnyawards waitlist webapp websockets; do # sanitize and trim string so that it can be used as a valid subdomain. Includes removing hyphens at the start and end of the name stage_name=`echo "stg-${app}-${stage_name_suffix}" | sed -E -e 's/[^a-zA-Z0-9-]+//g' -e 's/(.{40}).*/\1/' -e 's/^-/0/' -e 's/-$/0/'` @@ -87,6 +87,12 @@ jobs: token: ${{ steps.get-token.outputs.token }} environment: ${{ steps.destroy_aws_stack.outputs.scoutgamecron_env }} + - name: Delete Scout Game Coming Soon Github deployment + uses: strumwolf/delete-deployment-environment@v3 + with: + token: ${{ steps.get-token.outputs.token }} + environment: ${{ steps.destroy_aws_stack.outputs.scoutgameadmin_env }} + - name: Delete Sunny Awards Github deployment uses: strumwolf/delete-deployment-environment@v3 with: diff --git a/@connect-shared/lib/actions/actionClient.ts b/@connect-shared/lib/actions/actionClient.ts index 1f6a0145e5..9fb715bcbf 100644 --- a/@connect-shared/lib/actions/actionClient.ts +++ b/@connect-shared/lib/actions/actionClient.ts @@ -15,13 +15,15 @@ export function defineMetadataSchema() { }); } -export const actionClient = createSafeActionClient({ +export const actionClientBase = createSafeActionClient({ validationAdapter: yupAdapter(), defineMetadataSchema, handleReturnedServerError, handleServerErrorLog, defaultValidationErrorsShape: 'flattened' -}) +}); + +export const actionClient = actionClientBase /** * Middleware used for auth purposes. * Returns the context with the session object. diff --git a/@connect-shared/lib/session/getSession.ts b/@connect-shared/lib/session/getSession.ts index 6a529bcf3f..c6fd086dd0 100644 --- a/@connect-shared/lib/session/getSession.ts +++ b/@connect-shared/lib/session/getSession.ts @@ -4,6 +4,6 @@ import { cookies } from 'next/headers'; import type { SessionData } from './config'; import { getIronOptions } from './config'; -export async function getSession() { - return getIronSession(cookies(), getIronOptions()); +export async function getSession() { + return getIronSession(cookies(), getIronOptions()); } diff --git a/apps/scoutgame/scripts/query.ts b/apps/scoutgame/scripts/query.ts new file mode 100644 index 0000000000..8822c15dbb --- /dev/null +++ b/apps/scoutgame/scripts/query.ts @@ -0,0 +1,9 @@ +import { prisma } from '@charmverse/core/prisma-client'; +import { getUserByPath } from 'lib/users/getUserByPath'; + +async function query() { + const existingAccounts = await getUserByPath('thescoho'); + console.log(existingAccounts); +} + +query(); diff --git a/apps/scoutgameadmin/Dockerfile b/apps/scoutgameadmin/Dockerfile new file mode 100644 index 0000000000..80609e5b88 --- /dev/null +++ b/apps/scoutgameadmin/Dockerfile @@ -0,0 +1,36 @@ +#syntax=docker/dockerfile:1.7-labs + +# Use node-slim because node-alpine does not seem to supports the `sharp` npm library that gets built +FROM node:18.19.0-slim AS base-app + +# useful for node-alpine +# RUN apk add --no-cache libc6-compat git +RUN apt update +RUN apt install openssl -y + +WORKDIR /app + +# Copy dependencies +COPY --parents @connect-shared \ + apps/*/package.json \ + packages/*/package.json \ + node_modules \ + package.json \ + apps/scoutgameadmin/node_modules \ + apps/scoutgameadmin/package.json \ + . + +# Copy compiled code +COPY --parents apps/*/.next apps/*/public . + +ENV PORT=3000 + +# Next.js collects completely anonymous telemetry data about general usage. +# Learn more here: https://nextjs.org/telemetry +# Uncomment the following line in case you want to disable telemetry. +ENV NEXT_TELEMETRY_DISABLED=1 +ENV LOG_LEVEL=debug + +EXPOSE 3000 +# need something to keep docker container running until docker-compose runs its command +CMD ["tail", "-f", "/dev/null"] diff --git a/apps/scoutgameadmin/README.md b/apps/scoutgameadmin/README.md new file mode 100644 index 0000000000..835b2f0ac7 --- /dev/null +++ b/apps/scoutgameadmin/README.md @@ -0,0 +1 @@ +# connect.charmverse.io Coming Soon diff --git a/apps/scoutgameadmin/app/(general)/layout.tsx b/apps/scoutgameadmin/app/(general)/layout.tsx new file mode 100644 index 0000000000..717a82cd0c --- /dev/null +++ b/apps/scoutgameadmin/app/(general)/layout.tsx @@ -0,0 +1,24 @@ +import { Box } from '@mui/material'; +import type { ReactNode } from 'react'; + +import { Header } from 'components/common/Header'; +import { StickyFooter } from 'components/common/StickyFooter'; +import { getUserFromSession } from 'lib/session/getUserFromSession'; + +export default async function Layout({ + children +}: Readonly<{ + children: ReactNode; +}>) { + const user = await getUserFromSession(); + + return ( + +
+ + {children} + + + + ); +} diff --git a/apps/scoutgameadmin/app/(general)/repos/page.tsx b/apps/scoutgameadmin/app/(general)/repos/page.tsx new file mode 100644 index 0000000000..640d04610b --- /dev/null +++ b/apps/scoutgameadmin/app/(general)/repos/page.tsx @@ -0,0 +1,9 @@ +import { ReposDashboard } from 'components/repos/ReposDashboard'; +import { getRepos } from 'lib/repos/getRepos'; + +export const dynamic = 'force-dynamic'; + +export default async function Dashboard() { + const repos = await getRepos(); + return ; +} diff --git a/apps/scoutgameadmin/app/(general)/template.tsx b/apps/scoutgameadmin/app/(general)/template.tsx new file mode 100644 index 0000000000..f8b52b4fe4 --- /dev/null +++ b/apps/scoutgameadmin/app/(general)/template.tsx @@ -0,0 +1,6 @@ +import { Box } from '@mui/material'; +import type { ReactNode } from 'react'; + +export default function Template({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/apps/scoutgameadmin/app/(info)/layout.tsx b/apps/scoutgameadmin/app/(info)/layout.tsx new file mode 100644 index 0000000000..aac2da63a7 --- /dev/null +++ b/apps/scoutgameadmin/app/(info)/layout.tsx @@ -0,0 +1,18 @@ +import { Box } from '@mui/material'; +import type { ReactNode } from 'react'; + +import 'theme/styles.scss'; + +export default function Layout({ + children +}: Readonly<{ + children: ReactNode; +}>) { + return ( + + + {children} + + + ); +} diff --git a/apps/scoutgameadmin/app/(info)/login/page.tsx b/apps/scoutgameadmin/app/(info)/login/page.tsx new file mode 100644 index 0000000000..bc8af047e4 --- /dev/null +++ b/apps/scoutgameadmin/app/(info)/login/page.tsx @@ -0,0 +1,11 @@ +import type { Metadata } from 'next'; + +import { LoginPage as LoginPageComponent } from 'components/login/LoginPage'; + +export const metadata: Metadata = { + title: 'Login to Scout Game Admin' +}; + +export default async function LoginPage() { + return ; +} diff --git a/apps/scoutgameadmin/app/(info)/template.tsx b/apps/scoutgameadmin/app/(info)/template.tsx new file mode 100644 index 0000000000..0693c1773d --- /dev/null +++ b/apps/scoutgameadmin/app/(info)/template.tsx @@ -0,0 +1,6 @@ +import { Box } from '@mui/material'; +import type { ReactNode } from 'react'; + +export default function Template({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/apps/scoutgameadmin/app/api/github/search-repos/route.ts b/apps/scoutgameadmin/app/api/github/search-repos/route.ts new file mode 100644 index 0000000000..5c4765468e --- /dev/null +++ b/apps/scoutgameadmin/app/api/github/search-repos/route.ts @@ -0,0 +1,41 @@ +import { prisma } from '@charmverse/core/prisma-client'; +import { getReposByOwner } from '@packages/github/getReposByOwner'; +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; + +export type RepoSearchResult = { + id: number; + url: string; + fullName: string; + exists: boolean; +}; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const owner = searchParams.get('owner'); + if (!owner || owner.length < 2) { + return NextResponse.json([]); + } + try { + const repos = await getReposByOwner(owner); + const existing = await prisma.githubRepo.findMany({ + where: { + id: { + in: repos.map((repo) => repo.id) + } + } + }); + const result: RepoSearchResult[] = repos.map((repo) => ({ + id: repo.id, + fullName: repo.full_name, + url: repo.html_url, + exists: existing.some((e) => e.id === repo.id) + })); + return NextResponse.json(result); + } catch (error) { + if ((error as Error).message?.includes('HTTP error! status: 404')) { + return NextResponse.json({ message: 'Repository owner not found' }, { status: 404 }); + } + return NextResponse.json({ message: (error as Error).message || 'Something went wrong' }, { status: 500 }); + } +} diff --git a/apps/scoutgameadmin/app/api/health/route.ts b/apps/scoutgameadmin/app/api/health/route.ts new file mode 100644 index 0000000000..900a4d5649 --- /dev/null +++ b/apps/scoutgameadmin/app/api/health/route.ts @@ -0,0 +1,3 @@ +export function GET() { + return new Response('ok'); +} diff --git a/apps/scoutgameadmin/app/api/repos/export/route.ts b/apps/scoutgameadmin/app/api/repos/export/route.ts new file mode 100644 index 0000000000..2aea7aa9b8 --- /dev/null +++ b/apps/scoutgameadmin/app/api/repos/export/route.ts @@ -0,0 +1,20 @@ +import { prisma } from '@charmverse/core/prisma-client'; +import { stringify } from 'csv-stringify/sync'; +import { NextResponse } from 'next/server'; + +const columns = ['id', 'owner', 'name', 'url']; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + const rows = await prisma.githubRepo.findMany(); + const exportString = stringify(rows, { header: true, columns }); + return NextResponse.json(exportString); + // return new Response(exportString, { + // status: 200, + // headers: { + // 'Content-Type': 'text/tsv', + // 'Content-Disposition': 'attachment; filename=github_repos.tsv' + // } + // }); +} diff --git a/apps/scoutgameadmin/app/api/repos/route.ts b/apps/scoutgameadmin/app/api/repos/route.ts new file mode 100644 index 0000000000..fd3887f900 --- /dev/null +++ b/apps/scoutgameadmin/app/api/repos/route.ts @@ -0,0 +1,21 @@ +import { log } from '@charmverse/core/log'; +import { importReposByUser } from '@packages/scoutgame/importReposByUser'; +import type { NextRequest } from 'next/server'; +import { NextResponse } from 'next/server'; + +import { getRepos } from 'lib/repos/getRepos'; + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const searchString = searchParams.get('searchString'); + const repos = await getRepos({ searchString: searchString || undefined }); + return NextResponse.json(repos); +} + +export async function POST(request: NextRequest) { + const { owner } = await request.json(); + + await importReposByUser(owner); + + return NextResponse.json({ success: true }); +} diff --git a/apps/scoutgameadmin/app/api/users/export/route.ts b/apps/scoutgameadmin/app/api/users/export/route.ts new file mode 100644 index 0000000000..c708cff239 --- /dev/null +++ b/apps/scoutgameadmin/app/api/users/export/route.ts @@ -0,0 +1,50 @@ +import { prisma } from '@charmverse/core/prisma-client'; +import { stringify } from 'csv-stringify/sync'; +import type { NextRequest } from 'next/server'; + +export const dynamic = 'force-dynamic'; + +type ScoutWithGithubUser = { + id: string; + username: string; + // avatar: string; + builderStatus: string; + githubLogin?: string; + fid?: number; + farcasterName?: string; + currentBalance: number; +}; + +export async function GET(req: NextRequest) { + const users = await prisma.scout.findMany({ + select: { + id: true, + username: true, + avatar: true, + builderStatus: true, + farcasterId: true, + farcasterName: true, + currentBalance: true, + githubUser: true + } + }); + const rows: ScoutWithGithubUser[] = users.map((user) => ({ + id: user.id, + username: user.username, + // avatar: user.avatar || '', + builderStatus: user.builderStatus || '', + githubLogin: user.githubUser[0]?.login, + fid: user.farcasterId || undefined, + farcasterName: user.farcasterName || undefined, + currentBalance: user.currentBalance + })); + 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' + } + }); +} diff --git a/apps/scoutgameadmin/app/layout.tsx b/apps/scoutgameadmin/app/layout.tsx new file mode 100644 index 0000000000..3211beaea4 --- /dev/null +++ b/apps/scoutgameadmin/app/layout.tsx @@ -0,0 +1,53 @@ +import { AppProviders } from '@connect-shared/components/layout/AppProviders'; +import type { Metadata, Viewport } from 'next'; +import Script from 'next/script'; +import type { ReactNode } from 'react'; + +import theme from 'theme/theme'; + +import 'theme/styles.scss'; + +const appName = 'Scout Game Admin'; + +export const metadata: Metadata = { + applicationName: appName, + icons: { + icon: ['/favicon.ico'], + apple: ['/favicon.ico'] + }, + title: appName, + formatDetection: { + telephone: false + }, + openGraph: { + type: 'website', + siteName: appName, + images: 'https://scoutgame.xyz/images/manifest/scoutgame-logo-256.png', + title: appName, + description: 'Scout. Build. Win.' + }, + twitter: { + card: 'summary', + title: appName, + description: 'Scout. Build. Win.' + } +}; +export const viewport: Viewport = { + themeColor: '#000', + userScalable: false +}; +export default async function RootLayout({ + children +}: Readonly<{ + children: ReactNode; +}>) { + return ( + + + {/* load env vars for the frontend - note that the parent body tag is required for React to not complain */} +