diff --git a/.github/workflows/aws_auto_release.yml b/.github/workflows/aws_auto_release.yml new file mode 100644 index 0000000..c265155 --- /dev/null +++ b/.github/workflows/aws_auto_release.yml @@ -0,0 +1,239 @@ +name: Auto Release on Main Merge +on: + pull_request: + types: [closed] + branches: + - main + +concurrency: + group: ${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +permissions: + contents: write + pull-requests: read + +jobs: + auto_release: + runs-on: ubuntu-latest + if: github.event.pull_request.merged == true + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: main + token: ${{ secrets.PAT }} + + - name: Check if user is authorized + id: auth_check + run: | + merged_by="${{ github.event.pull_request.merged_by.login }}" + echo "PR was merged by: $merged_by" + + # Get authorized users from CODEOWNERS file + authorized_users=() + + # Read CODEOWNERS file if it exists + if [[ -f ".github/CODEOWNERS" ]]; then + echo "[INFO] Reading CODEOWNERS file..." + # Extract usernames from CODEOWNERS (remove @ prefix) + codeowners=$(grep -v '^#' .github/CODEOWNERS | grep -o '@[a-zA-Z0-9_-]*' | sed 's/@//' | sort -u) + for user in $codeowners; do + authorized_users+=("$user") + echo " - CODEOWNER: $user" + done + else + echo "[WARN] No CODEOWNERS file found" + fi + + # Get repository collaborators with admin/maintain permissions using GitHub API + echo "[CHECK] Checking repository permissions..." + + # Check if user has admin or maintain permissions + user_permission=$(curl -s -H "Authorization: token ${{ secrets.PAT }}" \ + -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/repos/${{ github.repository }}/collaborators/$merged_by/permission" | \ + jq -r '.permission // "none"') + + echo "User $merged_by has permission level: $user_permission" + + # Check if user is authorized + is_authorized=false + + # Check if user is in CODEOWNERS + for user in "${authorized_users[@]}"; do + if [[ "$user" == "$merged_by" ]]; then + is_authorized=true + echo "[OK] User $merged_by is authorized via CODEOWNERS" + break + fi + done + + # Check if user has admin or maintain permissions + if [[ "$user_permission" == "admin" || "$user_permission" == "maintain" ]]; then + is_authorized=true + echo "[OK] User $merged_by is authorized via repository permissions ($user_permission)" + fi + + # Check if user is organization owner (for metaversecloud-com org) + org_response=$(curl -s -H "Authorization: token ${{ secrets.PAT }}" \ + -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/orgs/metaversecloud-com/members/$merged_by" \ + -w "%{http_code}") + + # Extract HTTP status code from the response + http_code=${org_response: -3} + + if [[ "$http_code" == "200" ]]; then + # Check if user is an owner + owner_status=$(curl -s -H "Authorization: token ${{ secrets.PAT }}" \ + -H "Accept: application/vnd.github.v3+json" \ + "https://api.github.com/orgs/metaversecloud-com/memberships/$merged_by" | \ + jq -r '.role // "none"') + + if [[ "$owner_status" == "admin" ]]; then + is_authorized=true + echo "[OK] User $merged_by is authorized as organization owner" + fi + fi + + echo "is_authorized=$is_authorized" >> $GITHUB_OUTPUT + + if [[ "$is_authorized" == "false" ]]; then + echo "[ERROR] User $merged_by is not authorized to trigger releases" + echo "[TIP] Authorized users include:" + echo " - CODEOWNERS: ${authorized_users[*]}" + echo " - Repository admins and maintainers" + echo " - Organization owners" + exit 0 + else + echo "[SUCCESS] User $merged_by is authorized to trigger releases" + fi + + - name: Check for release labels and determine version bumps + if: steps.auth_check.outputs.is_authorized == 'true' + id: check + run: | + labels='${{ toJson(github.event.pull_request.labels.*.name) }}' + echo "PR Labels: $labels" + + has_release_label=false + has_major=false + has_minor=false + has_patch=false + + # Check if release label exists + if echo "$labels" | grep -q "release"; then + has_release_label=true + + # Check for each type of version bump + if echo "$labels" | grep -q "major"; then + has_major=true + fi + if echo "$labels" | grep -q "minor"; then + has_minor=true + fi + if echo "$labels" | grep -q "patch"; then + has_patch=true + fi + + # If no specific version type is specified, default to patch + if [[ "$has_major" == "false" && "$has_minor" == "false" && "$has_patch" == "false" ]]; then + has_patch=true + fi + fi + + echo "should_release=$has_release_label" >> $GITHUB_OUTPUT + echo "has_major=$has_major" >> $GITHUB_OUTPUT + echo "has_minor=$has_minor" >> $GITHUB_OUTPUT + echo "has_patch=$has_patch" >> $GITHUB_OUTPUT + echo "Should release: $has_release_label" + echo "Has major: $has_major, minor: $has_minor, patch: $has_patch" + + - name: Setup Node.js + if: steps.auth_check.outputs.is_authorized == 'true' && steps.check.outputs.should_release == 'true' + uses: actions/setup-node@v4 + with: + node-version: 20.10 + + - name: Calculate new version with cumulative bumps + if: steps.auth_check.outputs.is_authorized == 'true' && steps.check.outputs.should_release == 'true' + id: version + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + + # Get the latest tag from git + latest_tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0") + echo "Latest git tag: $latest_tag" + + # Remove 'v' prefix if present + current_version=${latest_tag#v} + echo "Current version: $current_version" + + # Parse current version + IFS='.' read -r major minor patch <<< "$current_version" + echo "Parsed version - Major: $major, Minor: $minor, Patch: $patch" + + # Apply cumulative version bumps + if [[ "${{ steps.check.outputs.has_major }}" == "true" ]]; then + major=$((major + 1)) + minor=0 # Reset minor when major is bumped + patch=0 # Reset patch when major is bumped + echo "Applied major bump: $major.0.0" + fi + + if [[ "${{ steps.check.outputs.has_minor }}" == "true" ]]; then + minor=$((minor + 1)) + if [[ "${{ steps.check.outputs.has_major }}" != "true" ]]; then + patch=0 # Reset patch when minor is bumped (only if major wasn't bumped) + fi + echo "Applied minor bump: $major.$minor.$patch" + fi + + if [[ "${{ steps.check.outputs.has_patch }}" == "true" ]]; then + patch=$((patch + 1)) + echo "Applied patch bump: $major.$minor.$patch" + fi + + new_version="$major.$minor.$patch" + echo "Final calculated version: $new_version" + + # Create package.json if it doesn't exist + if [[ ! -f "package.json" ]]; then + echo '{"version": "0.0.0"}' > package.json + fi + + # Update package.json with new version + npm version $new_version --no-git-tag-version --allow-same-version + + echo "NEW_VERSION=v$new_version" >> $GITHUB_ENV + echo "New version will be: v$new_version" + + - name: Create Release + if: steps.auth_check.outputs.is_authorized == 'true' && steps.check.outputs.should_release == 'true' + uses: softprops/action-gh-release@v2 + with: + token: ${{ secrets.PAT }} # Use PAT to trigger other workflows + tag_name: ${{ env.NEW_VERSION }} + name: "Release ${{ env.NEW_VERSION }}" + generate_release_notes: true + make_latest: true + body: | + ## ? Release ${{ env.NEW_VERSION }} + + **Version Bumps Applied:** + - Major: ${{ steps.check.outputs.has_major }} + - Minor: ${{ steps.check.outputs.has_minor }} + - Patch: ${{ steps.check.outputs.has_patch }} + + **Triggered by:** PR #${{ github.event.pull_request.number }} - ${{ github.event.pull_request.title }} + **Merged by:** @${{ github.event.pull_request.merged_by.login }} + + ### Changes in this PR + ${{ github.event.pull_request.body }} + + --- + *This release was automatically created by the Auto Release workflow* + diff --git a/.github/workflows/aws_prod_release.yml b/.github/workflows/aws_prod_release.yml index 43caaf6..3550e1a 100644 --- a/.github/workflows/aws_prod_release.yml +++ b/.github/workflows/aws_prod_release.yml @@ -50,7 +50,7 @@ jobs: cache: 'npm' - run: git config --global user.email devops@topia.io - run: git config --global user.name Devops - - run: npm version --workspaces --include-workspace-root true ${{ github.event.release.tag_name }} + - run: npm version --no-git-tag-version --workspaces --include-workspace-root true ${{ github.event.release.tag_name }} - run: npm i - run: CI=false npm run build diff --git a/client/src/components/Leaderboard.tsx b/client/src/components/Leaderboard.tsx index 1cae0cb..e819bbf 100644 --- a/client/src/components/Leaderboard.tsx +++ b/client/src/components/Leaderboard.tsx @@ -4,11 +4,11 @@ import { useContext, useEffect, useState } from "react"; import { Loading } from "./Loading"; // context -import { GlobalDispatchContext, GlobalStateContext } from "@context/GlobalContext"; -import { SET_VISITOR_INFO } from "@/context/types"; +import { GlobalStateContext } from "@context/GlobalContext"; // utils import { backendAPI } from "@utils/backendAPI"; +import { useSearchParams } from "react-router-dom"; type LeaderboardType = { name: string; @@ -25,22 +25,18 @@ export const Leaderboard = ({ isKeyAsset }: { isKeyAsset: boolean }) => { const [isLoading, setIsLoading] = useState(true); // context - const dispatch = useContext(GlobalDispatchContext); - const { questDetails, visitor } = useContext(GlobalStateContext); + const { questDetails } = useContext(GlobalStateContext); const { questItemImage } = questDetails || {}; + const [searchParams] = useSearchParams(); + const profileId = searchParams.get("profileId"); + useEffect(() => { backendAPI .get(`/leaderboard?isKeyAsset=${isKeyAsset}`) .then((response) => { - const { leaderboard, visitor } = response.data; - - dispatch!({ - type: SET_VISITOR_INFO, - payload: { visitor }, - }); - - const index = leaderboard.findIndex((item: { profileId: string }) => item.profileId === visitor.profileId); + const { leaderboard } = response.data; + const index = leaderboard.findIndex((item: { profileId: string }) => item.profileId === profileId); setMyData(leaderboard[index]); setCurrentPosition(index + 1); setTotal(leaderboard.length); @@ -49,12 +45,10 @@ export const Leaderboard = ({ isKeyAsset }: { isKeyAsset: boolean }) => { }) .catch(() => console.error("There was a problem while retrieving leaderboard data. Please try again later.")) .finally(() => setIsLoading(false)); - }, [visitor]); + }, []); if (isLoading) return ; - if (visibleData.length === 0) return

No quest items have been found yet. Search the world and be the first!

; - return (
{currentPosition && currentPosition > 0 ? ( diff --git a/client/src/components/PageContainer.tsx b/client/src/components/PageContainer.tsx index a35d1f0..f12f740 100644 --- a/client/src/components/PageContainer.tsx +++ b/client/src/components/PageContainer.tsx @@ -6,18 +6,27 @@ import { AdminIconButton, Loading, Admin } from "@/components"; // context import { GlobalStateContext } from "@context/GlobalContext"; -export const PageContainer = ({ children, isLoading }: { children: ReactNode; isLoading: boolean }) => { - const { error, questDetails, visitor } = useContext(GlobalStateContext); +export const PageContainer = ({ + children, + isLoading, + showAdminIcon, +}: { + children: ReactNode; + isLoading: boolean; + showAdminIcon: boolean; +}) => { + const { error, questDetails, visitor, badges, visitorInventory } = useContext(GlobalStateContext); const { questItemImage } = questDetails || {}; const { isAdmin } = visitor || {}; + const [activeTab, setActiveTab] = useState("leaderboard"); const [showSettings, setShowSettings] = useState(false); if (isLoading) return ; return (
- {isAdmin && ( + {isAdmin && showAdminIcon && ( setShowSettings(!showSettings)} showSettings={showSettings} /> )} {questItemImage ? Find me :
} @@ -28,10 +37,39 @@ export const PageContainer = ({ children, isLoading }: { children: ReactNode; is ) : ( <> - {children} - {error &&

{error}

} +
+ + +
+ + {activeTab === "leaderboard" ? ( + children + ) : ( +
+ {badges && + Object.values(badges).map((badge) => { + const hasBadge = visitorInventory && Object.keys(visitorInventory).includes(badge.name); + const style = { width: "90px", filter: "none" }; + if (!hasBadge) style.filter = "grayscale(1)"; + return ( +
+ {badge.name} + {badge.name} +
+ ); + })} +
+ )} )} + {error &&

{error}

}
); }; diff --git a/client/src/context/reducer.ts b/client/src/context/reducer.ts index 6bb829e..b03ee61 100644 --- a/client/src/context/reducer.ts +++ b/client/src/context/reducer.ts @@ -19,16 +19,18 @@ const globalReducer = (state: InitialState, action: ActionType) => { return { ...state, questDetails: payload.questDetails, + badges: payload.badges, }; case SET_VISITOR_INFO: return { ...state, visitor: payload.visitor, + visitorInventory: payload.visitorInventory, }; case SET_ERROR: return { ...state, - error: payload?.error, + error: payload.error, }; default: { throw new Error(`Unhandled action type: ${type}`); diff --git a/client/src/context/types.ts b/client/src/context/types.ts index f56a144..1db9ff4 100644 --- a/client/src/context/types.ts +++ b/client/src/context/types.ts @@ -25,6 +25,15 @@ export interface InitialState { isAdmin: boolean; profileId: string; }; + visitorInventory?: { [name: string]: { id: string; icon: string; name: string } }; + badges?: { + [name: string]: { + id: string; + name: string; + icon: string; + description: string; + }; + }; error?: string; } diff --git a/client/src/pages/Home.tsx b/client/src/pages/Home.tsx index 082fc76..8e1d47f 100644 --- a/client/src/pages/Home.tsx +++ b/client/src/pages/Home.tsx @@ -22,14 +22,14 @@ export const Home = () => { backendAPI .get("/quest") .then((response) => { - const { questDetails, visitor } = response.data; + const { questDetails, visitor, badges, visitorInventory } = response.data; dispatch!({ type: SET_QUEST_DETAILS, - payload: { questDetails }, + payload: { questDetails, badges }, }); dispatch!({ type: SET_VISITOR_INFO, - payload: { visitor }, + payload: { visitor, visitorInventory }, }); }) .catch((error) => setErrorMessage(dispatch, error as ErrorType)) @@ -38,7 +38,7 @@ export const Home = () => { }, [hasInteractiveParams]); return ( - + ); diff --git a/client/src/pages/QuestItemClicked.tsx b/client/src/pages/QuestItemClicked.tsx index e8fb57a..c144d2e 100644 --- a/client/src/pages/QuestItemClicked.tsx +++ b/client/src/pages/QuestItemClicked.tsx @@ -1,11 +1,11 @@ import { useContext, useEffect, useState } from "react"; // components -import { Leaderboard, Loading } from "@/components"; +import { Leaderboard, Loading, PageContainer } from "@/components"; // context import { GlobalDispatchContext, GlobalStateContext } from "@context/GlobalContext"; -import { SET_QUEST_DETAILS } from "@/context/types"; +import { SET_QUEST_DETAILS, SET_VISITOR_INFO } from "@/context/types"; // utils import { backendAPI } from "@utils/backendAPI"; @@ -17,18 +17,29 @@ export const QuestItemClicked = () => { // context const dispatch = useContext(GlobalDispatchContext); - const { questDetails, hasInteractiveParams } = useContext(GlobalStateContext); - const { questItemImage } = questDetails || {}; + const { hasInteractiveParams } = useContext(GlobalStateContext); useEffect(() => { if (hasInteractiveParams) { backendAPI .post("/quest-item-clicked") .then((response) => { - const { addedClick, numberAllowedToCollect, totalCollectedToday, questDetails } = response.data; + const { + addedClick, + numberAllowedToCollect, + totalCollectedToday, + questDetails, + visitor, + visitorInventory, + badges, + } = response.data; dispatch!({ type: SET_QUEST_DETAILS, - payload: { questDetails }, + payload: { questDetails, badges }, + }); + dispatch!({ + type: SET_VISITOR_INFO, + payload: { visitor, visitorInventory }, }); if (addedClick) { setCollectedText(`${totalCollectedToday}/${numberAllowedToCollect} collected today`); @@ -50,25 +61,13 @@ export const QuestItemClicked = () => { if (isLoading) return ; return ( -
- {questItemImage ? Find me :
} -
-

Quest

-
-
- {message && ( -
-

{message}

-
- )} - {collectedText && ( -
-

{collectedText}

-
- )} + +
+ {message &&

{message}

} + {collectedText &&

{collectedText}

} +
- -
+ ); }; diff --git a/package-lock.json b/package-lock.json index 1690db4..d064eee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ ], "dependencies": { "@googleapis/sheets": "^7.0.0", - "@rtsdk/topia": "^0.15.8", + "@rtsdk/topia": "^0.19.4", "axios": "^1.6.8", "concurrently": "^8.2.2", "typescript": "^5.4.3", @@ -1035,9 +1035,9 @@ ] }, "node_modules/@rtsdk/topia": { - "version": "0.15.8", - "resolved": "https://registry.npmjs.org/@rtsdk/topia/-/topia-0.15.8.tgz", - "integrity": "sha512-T0+pZxqMn6OcDzNIGk50B3lgRWoDBOXSmwShOepNGUyM9t7Yuu7NAMsYHKYEUfXN7X948bW5CetfwWk9Cd2K4w==" + "version": "0.19.4", + "resolved": "https://registry.npmjs.org/@rtsdk/topia/-/topia-0.19.4.tgz", + "integrity": "sha512-kpgWODTaUHQwyDn56rAtDZP6Xg7ug9Z5qDHTR8bzmFOnNFFeFuYrYUc2o8MGApstRVKnAYJaGLXcr4haVZqVaQ==" }, "node_modules/@sdk-quest/client": { "resolved": "client", diff --git a/package.json b/package.json index 1f8586c..a82a0ea 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ }, "dependencies": { "@googleapis/sheets": "^7.0.0", - "@rtsdk/topia": "^0.15.8", + "@rtsdk/topia": "^0.19.4", "axios": "^1.6.8", "concurrently": "^8.2.2", "typescript": "^5.4.3", diff --git a/server/controllers/droppedAssets/handleDropQuestItem.ts b/server/controllers/droppedAssets/handleDropQuestItem.ts index bcfe9d2..ff57825 100644 --- a/server/controllers/droppedAssets/handleDropQuestItem.ts +++ b/server/controllers/droppedAssets/handleDropQuestItem.ts @@ -16,7 +16,11 @@ export const handleDropQuestItem = async (req: Request, res: Response) => { const { interactivePublicKey, urlSlug } = credentials; const sceneDropId = credentials.sceneDropId || credentials.assetId; - const { dataObject, world } = await getWorldDetails(credentials, true); + const getWorldDetailsResponse = await getWorldDetails(credentials, true); + if (getWorldDetailsResponse instanceof Error) throw getWorldDetailsResponse; + + const { dataObject, world } = getWorldDetailsResponse; + const { questItemImage } = dataObject as WorldDataObjectType; if (!questItemImage) throw "questItemImage is required"; diff --git a/server/controllers/droppedAssets/handleGetQuestDetails.ts b/server/controllers/droppedAssets/handleGetQuestDetails.ts index 6bd2d5c..891aba0 100644 --- a/server/controllers/droppedAssets/handleGetQuestDetails.ts +++ b/server/controllers/droppedAssets/handleGetQuestDetails.ts @@ -1,17 +1,27 @@ import { Request, Response } from "express"; -import { errorHandler, getCredentials, getVisitor, getWorldDetails } from "../../utils/index.js"; +import { errorHandler, getBadges, getCredentials, getVisitor, getWorldDetails } from "../../utils/index.js"; export const handleGetQuestDetails = async (req: Request, res: Response) => { try { const credentials = getCredentials(req.query); - const { dataObject } = await getWorldDetails(credentials, false); + const getWorldDetailsResponse = await getWorldDetails(credentials, false); + if (getWorldDetailsResponse instanceof Error) throw getWorldDetailsResponse; - const { visitor } = await getVisitor(credentials, credentials.assetId); + const { dataObject } = getWorldDetailsResponse; + + const getVisitorResponse = await getVisitor(credentials, credentials.assetId); + if (getVisitorResponse instanceof Error) throw getVisitorResponse; + + const { visitor, visitorInventory } = getVisitorResponse; + + const badges = await getBadges(credentials); return res.json({ questDetails: dataObject, visitor: { isAdmin: visitor.isAdmin, profileId: credentials.profileId }, + visitorInventory, + badges, }); } catch (error) { return errorHandler({ diff --git a/server/controllers/droppedAssets/handleGetQuestItems.ts b/server/controllers/droppedAssets/handleGetQuestItems.ts index 133ea41..b023457 100644 --- a/server/controllers/droppedAssets/handleGetQuestItems.ts +++ b/server/controllers/droppedAssets/handleGetQuestItems.ts @@ -5,7 +5,10 @@ export const handleGetQuestItems = async (req: Request, res: Response) => { try { const credentials = getCredentials(req.query); - const droppedAssets = await getQuestItems(credentials); + const getQuestItemsResponse = await getQuestItems(credentials); + if (getQuestItemsResponse instanceof Error) throw getQuestItemsResponse; + + const droppedAssets = getQuestItemsResponse; return res.json({ droppedAssets }); } catch (error) { diff --git a/server/controllers/droppedAssets/handleQuestItemClicked.ts b/server/controllers/droppedAssets/handleQuestItemClicked.ts index 2e187e5..b1ca082 100644 --- a/server/controllers/droppedAssets/handleQuestItemClicked.ts +++ b/server/controllers/droppedAssets/handleQuestItemClicked.ts @@ -11,6 +11,8 @@ import { getRandomCoordinates, getVisitor, getWorldDetails, + awardBadge, + getBadges, } from "../../utils/index.js"; import { AxiosError } from "axios"; @@ -25,27 +27,55 @@ export const handleQuestItemClicked = async (req: Request, res: Response) => { const currentDate = new Date(localDateString); currentDate.setHours(0, 0, 0, 0); - const analytics = []; + const analytics = [], + promises = []; const questItem = await DroppedAsset.get(assetId, urlSlug, { credentials }); - const { dataObject: worldDataObject, world } = await getWorldDetails(credentials, true); + const getWorldDetailsResponse = await getWorldDetails(credentials, true); + if (getWorldDetailsResponse instanceof Error) throw getWorldDetailsResponse; + + const { dataObject: worldDataObject, world } = getWorldDetailsResponse; + let { keyAssetId, numberAllowedToCollect } = worldDataObject as WorldDataObjectType; if (typeof numberAllowedToCollect === "string") numberAllowedToCollect = parseInt(numberAllowedToCollect); - const { visitor, visitorProgress } = await getVisitor(credentials, keyAssetId); + const getVisitorResponse = await getVisitor(credentials, keyAssetId); + if (getVisitorResponse instanceof Error) throw getVisitorResponse; + + const { visitor, visitorProgress, visitorInventory } = getVisitorResponse; let { currentStreak, lastCollectedDate, longestStreak, totalCollected, totalCollectedToday } = visitorProgress; + const badges = await getBadges(credentials); + + // Award First Find badge if visitor collected their first quest item + if (totalCollected === 0 || !visitorInventory["First Find"]) { + promises.push( + awardBadge({ credentials, visitor, visitorInventory, badgeName: "First Find" }).catch((error) => + errorHandler({ + error, + functionName: "handleQuestItemClicked", + message: "Error awarding First Find badge", + }), + ), + ); + } + const differenceInDays = getDifferenceInDays(currentDate, new Date(lastCollectedDate)); const hasCollectedToday = differenceInDays === 0; if (!hasCollectedToday) analytics.push({ analyticName: "starts", profileId, urlSlug, uniqueKey: profileId }); if (hasCollectedToday && totalCollectedToday >= numberAllowedToCollect) { - return res.json({ addedClick: false, numberAllowedToCollect, questDetails: worldDataObject }); + return res.json({ + addedClick: false, + numberAllowedToCollect, + questDetails: worldDataObject, + badges, + visitorInventory, + }); } else { - const promises = []; analytics.push({ analyticName: "itemsCollected" }); // Move the quest item to a new random location @@ -61,18 +91,66 @@ export const handleQuestItemClicked = async (req: Request, res: Response) => { }), ); - world.triggerParticle({ position: questItem.position, name: "lightBlueSmoke_puff" }).catch((error: AxiosError) => - errorHandler({ - error, - functionName: "handleQuestItemClicked", - message: "Error triggering particle effects", - }), + promises.push( + world + .triggerParticle({ position: questItem.position, name: "lightBlueSmoke_puff" }) + .catch((error: AxiosError) => + errorHandler({ + error, + functionName: "handleQuestItemClicked", + message: "Error triggering particle effects", + }), + ), ); totalCollected = totalCollected + 1; + + // Award Quest Veteran badges if visitor collected [x] quest items + let veteranBadgeName; + if (totalCollected === 25) veteranBadgeName = "Quest Veteran - Bronze"; + else if (totalCollected === 50) veteranBadgeName = "Quest Veteran - Silver"; + else if (totalCollected === 75) veteranBadgeName = "Quest Veteran - Gold"; + else if (totalCollected === 100) veteranBadgeName = "Quest Veteran - Diamond"; + if (veteranBadgeName) { + promises.push( + awardBadge({ credentials, visitor, visitorInventory, badgeName: veteranBadgeName }).catch((error) => + errorHandler({ + error, + functionName: "handleQuestItemClicked", + message: `Error awarding ${veteranBadgeName} badge`, + }), + ), + ); + } + if (!hasCollectedToday) { totalCollectedToday = 1; - if (differenceInDays === 1) currentStreak = currentStreak + 1; + if (differenceInDays === 1) { + currentStreak = currentStreak + 1; + + // Award Streak badges if visitor collected their item for 3 or 5 days in a row + if (currentStreak === 3) { + promises.push( + awardBadge({ credentials, visitor, visitorInventory, badgeName: "3-Day Streak" }).catch((error) => + errorHandler({ + error, + functionName: "handleQuestItemClicked", + message: "Error awarding 3-Day Streak badge", + }), + ), + ); + } else if (currentStreak === 5) { + promises.push( + awardBadge({ credentials, visitor, visitorInventory, badgeName: "5-Day Streak" }).catch((error) => + errorHandler({ + error, + functionName: "handleQuestItemClicked", + message: "Error awarding 5-Day Streak badge", + }), + ), + ); + } + } } else { totalCollectedToday = totalCollectedToday + 1; } @@ -80,35 +158,53 @@ export const handleQuestItemClicked = async (req: Request, res: Response) => { if (currentStreak > longestStreak) longestStreak = currentStreak; if (totalCollectedToday === numberAllowedToCollect) { - visitor.triggerParticle({ duration: 60, name: "redPinkHeart_float" }).catch((error: AxiosError) => - errorHandler({ - error, - functionName: "handleQuestItemClicked", - message: "Error triggering particle effects", - }), + promises.push( + visitor.triggerParticle({ duration: 60, name: "redPinkHeart_float" }).catch((error: AxiosError) => + errorHandler({ + error, + functionName: "handleQuestItemClicked", + message: "Error triggering particle effects", + }), + ), ); analytics.push({ analyticName: "completions", profileId, urlSlug, uniqueKey: profileId }); - addNewRowToGoogleSheets([ - { - identityId, - displayName, - event: "completions", - urlSlug, - }, - ]); + promises.push( + addNewRowToGoogleSheets([ + { + identityId, + displayName, + event: "completions", + urlSlug, + }, + ]), + ); + + // Award Inventory Pro badge if visitor has collected all allowed quest items for the day + promises.push( + awardBadge({ credentials, visitor, visitorInventory, badgeName: "Inventory Pro" }).catch((error) => + errorHandler({ + error, + functionName: "handleQuestItemClicked", + message: "Error awarding Inventory Pro badge", + }), + ), + ); } promises.push( - visitor.updateDataObject({ - [`${urlSlug}-${sceneDropId}`]: { - currentStreak, - lastCollectedDate: currentDate, - longestStreak, - totalCollected, - totalCollectedToday, + visitor.updateDataObject( + { + [`${urlSlug}-${sceneDropId}`]: { + currentStreak, + lastCollectedDate: currentDate, + longestStreak, + totalCollected, + totalCollectedToday, + }, }, - }), + {}, + ), ); if (totalCollected % 50 === 0) { @@ -121,36 +217,45 @@ export const handleQuestItemClicked = async (req: Request, res: Response) => { text = "Congrats! Your detective skills paid off."; // @ts-ignore if (grantExpressionResult.data?.statusCode === 200 || grantExpressionResult.status === 200) { - visitor.triggerParticle({ name: "firework2_gold" }).catch((error: AxiosError) => - errorHandler({ - error, - functionName: "handleQuestItemClicked", - message: "Error triggering particle effects", - }), + promises.push( + visitor.triggerParticle({ name: "firework2_gold" }).catch((error: AxiosError) => + errorHandler({ + error, + functionName: "handleQuestItemClicked", + message: "Error triggering particle effects", + }), + ), ); analytics.push({ analyticName: `${name}-emoteUnlocked`, urlSlug, uniqueKey: urlSlug }); + // @ts-ignore } else if (grantExpressionResult.data?.statusCode === 409 || grantExpressionResult.status === 409) { title = `Congrats! You collected ${totalCollected} quest items`; text = "Keep up the solid detective work 🔎"; } - visitor - .fireToast({ - groupId: "QuestExpression", - title, - text, - }) - .catch((error: AxiosError) => - errorHandler({ - error, - functionName: "handleQuestItemClicked", - message: "Error firing toast", - }), - ); + promises.push( + visitor + .fireToast({ + groupId: "QuestExpression", + title, + text, + }) + .catch((error: AxiosError) => + errorHandler({ + error, + functionName: "handleQuestItemClicked", + message: "Error firing toast", + }), + ), + ); } - const keyAsset = await getKeyAsset(credentials, keyAssetId); + const getKeyAssetResponse = await getKeyAsset(credentials, keyAssetId); + if (getKeyAssetResponse instanceof Error) throw getKeyAssetResponse; + + const keyAsset = getKeyAssetResponse; + promises.push( keyAsset.updateDataObject( { @@ -160,13 +265,23 @@ export const handleQuestItemClicked = async (req: Request, res: Response) => { ), ); - await Promise.all(promises); + const results = await Promise.allSettled(promises); + results.forEach((result) => { + if (result.status === "rejected") console.error(result.reason); + }); + + const getVisitorResponse = await getVisitor(credentials, keyAssetId); + if (getVisitorResponse instanceof Error) throw getVisitorResponse; + + const { visitorInventory: updatedInventory } = getVisitorResponse; return res.json({ addedClick: true, numberAllowedToCollect, totalCollectedToday, questDetails: worldDataObject, + badges, + visitorInventory: updatedInventory, }); } } catch (error) { diff --git a/server/controllers/handleGetLeaderboard.ts b/server/controllers/handleGetLeaderboard.ts index 4c93057..d585fc2 100644 --- a/server/controllers/handleGetLeaderboard.ts +++ b/server/controllers/handleGetLeaderboard.ts @@ -9,12 +9,17 @@ export const handleGetLeaderboard = async (req: Request, res: Response) => { let keyAssetId = credentials.assetId; if (!isKeyAsset) { - const { dataObject } = await getWorldDetails(credentials, false); + const getWorldDetailsResponse = await getWorldDetails(credentials, false); + if (getWorldDetailsResponse instanceof Error) throw getWorldDetailsResponse; + + const { dataObject } = getWorldDetailsResponse; keyAssetId = dataObject.keyAssetId; } - const keyAsset = await getKeyAsset(credentials, keyAssetId); + const getKeyAssetResponse = await getKeyAsset(credentials, keyAssetId); + if (getKeyAssetResponse instanceof Error) throw getKeyAssetResponse; + const keyAsset = getKeyAssetResponse; const { leaderboard } = (keyAsset.dataObject as KeyAssetDataObjectType) || {}; let formattedLeaderboard = []; @@ -36,11 +41,8 @@ export const handleGetLeaderboard = async (req: Request, res: Response) => { formattedLeaderboard.sort((a, b) => b.collected - a.collected); - const { visitor } = await getVisitor(credentials, keyAssetId); - return res.json({ leaderboard: formattedLeaderboard, - visitor: { isAdmin: visitor.isAdmin, profileId: credentials.profileId }, }); } catch (error) { return errorHandler({ diff --git a/server/controllers/handleRemoveQuestFromWorld.ts b/server/controllers/handleRemoveQuestFromWorld.ts index 095d9fe..a16b6a0 100644 --- a/server/controllers/handleRemoveQuestFromWorld.ts +++ b/server/controllers/handleRemoveQuestFromWorld.ts @@ -16,11 +16,15 @@ export const handleRemoveQuestFromWorld = async (req: Request, res: Response) => const sceneDropId = credentials.sceneDropId || assetId; // remove all quest items - const { success } = await removeQuestItems(credentials); - if (!success) throw "Error removing quest items."; + const removeQuestItemsResponse = await removeQuestItems(credentials); + if (removeQuestItemsResponse instanceof Error) throw removeQuestItemsResponse; // remove data from world data object - const { world } = await getWorldDetails(credentials, false); + const getWorldDetailsResponse = await getWorldDetails(credentials, false); + if (getWorldDetailsResponse instanceof Error) throw getWorldDetailsResponse; + + const { world } = getWorldDetailsResponse; + await world.updateDataObject( { [`scenes.${sceneDropId}`]: `Removed from world on ${new Date()}`, diff --git a/server/controllers/handleUpdateAdminSettings.ts b/server/controllers/handleUpdateAdminSettings.ts index 1fadb79..cfa0319 100644 --- a/server/controllers/handleUpdateAdminSettings.ts +++ b/server/controllers/handleUpdateAdminSettings.ts @@ -9,7 +9,10 @@ export const handleUpdateAdminSettings = async (req: Request, res: Response) => if (!questItemImage) throw "questItemImage is required"; - const { world } = await getWorldDetails(credentials, false); + const getWorldDetailsResponse = await getWorldDetails(credentials, false); + if (getWorldDetailsResponse instanceof Error) throw getWorldDetailsResponse; + + const { world } = getWorldDetailsResponse; const lockId = `${sceneDropId}-adminUpdates-${new Date(Math.round(new Date().getTime() / 10000) * 10000)}`; await world.updateDataObject( @@ -20,7 +23,11 @@ export const handleUpdateAdminSettings = async (req: Request, res: Response) => { lock: { lockId, releaseLock: true } }, ); - const droppedAssets = await getQuestItems(credentials); + const getQuestItemsResponse = await getQuestItems(credentials); + if (getQuestItemsResponse instanceof Error) throw getQuestItemsResponse; + + const droppedAssets = getQuestItemsResponse; + if (Object.keys(droppedAssets).length > 0) { const promises: any[] = []; Object.values(droppedAssets).map((droppedAsset: any) => { @@ -32,7 +39,7 @@ export const handleUpdateAdminSettings = async (req: Request, res: Response) => await world.fetchDataObject(); - return res.json({ questDetails: world.dataObject.scenes?.[sceneDropId] }); + return res.json({ questDetails: world.dataObject?.scenes?.[sceneDropId] }); } catch (error) { return errorHandler({ error, diff --git a/server/types/DataObjectTypes.ts b/server/types/DataObjectTypes.ts index d595e31..c3011b9 100644 --- a/server/types/DataObjectTypes.ts +++ b/server/types/DataObjectTypes.ts @@ -11,13 +11,15 @@ export type WorldDataObjectType = { questItemImage: string; }; +export type VisitorProgressType = { + currentStreak: number; + lastCollectedDate: Date; + longestStreak: number; + totalCollected: number; + totalCollectedToday: number; +}; + export type UserDataObjectType = { - [key: string]: { - // key = `${urlSlug}-${sceneDropId}` - currentStreak: number; - lastCollectedDate: Date; - longestStreak: number; - totalCollected: number; - totalCollectedToday: number; - }; + // key = `${urlSlug}-${sceneDropId}` + [key: string]: VisitorProgressType; }; diff --git a/server/utils/awardBadge.ts b/server/utils/awardBadge.ts new file mode 100644 index 0000000..5066363 --- /dev/null +++ b/server/utils/awardBadge.ts @@ -0,0 +1,37 @@ +import { Credentials } from "../types"; +import { Ecosystem, standardizeError } from "./index.js"; + +export const awardBadge = async ({ + credentials, + visitor, + visitorInventory, + badgeName, +}: { + credentials: Credentials; + visitor: any; + visitorInventory: any; + badgeName: string; +}) => { + try { + if (visitorInventory[badgeName]) return { success: true }; + + const ecosystem = await Ecosystem.create({ credentials }); + await ecosystem.fetchInventoryItems(); + + const inventoryItem = ecosystem.inventoryItems?.find((item) => item.name === badgeName); + if (!inventoryItem) throw new Error(`Inventory item ${badgeName} not found in ecosystem`); + + await visitor.grantInventoryItem(inventoryItem, 1); + + await visitor + .fireToast({ + title: "Badge Awarded", + text: `You have earned the ${badgeName} badge!`, + }) + .catch(() => console.error(`Failed to fire toast after awarding the ${badgeName} badge.`)); + + return { success: true }; + } catch (error: any) { + return standardizeError(error); + } +}; diff --git a/server/utils/droppedAssets/getKeyAsset.ts b/server/utils/droppedAssets/getKeyAsset.ts index 6d58e5c..e2fc9da 100644 --- a/server/utils/droppedAssets/getKeyAsset.ts +++ b/server/utils/droppedAssets/getKeyAsset.ts @@ -1,7 +1,7 @@ import { DroppedAsset } from "../topiaInit.js"; -import { errorHandler } from "../errorHandler.js"; import { Credentials } from "../../types/Credentials.js"; import { KeyAssetDataObjectType } from "../../types/DataObjectTypes.js"; +import { standardizeError } from "../standardizeError.js"; export const getKeyAsset = async (credentials: Credentials, keyAssetId: string) => { try { @@ -27,10 +27,6 @@ export const getKeyAsset = async (credentials: Credentials, keyAssetId: string) return keyAsset; } catch (error) { - return errorHandler({ - error, - functionName: "getKeyAsset", - message: "Error getting key asset", - }); + return standardizeError(error); } }; diff --git a/server/utils/droppedAssets/getQuestItems.ts b/server/utils/droppedAssets/getQuestItems.ts index 0503415..6f0ab97 100644 --- a/server/utils/droppedAssets/getQuestItems.ts +++ b/server/utils/droppedAssets/getQuestItems.ts @@ -1,6 +1,6 @@ import { World } from "../topiaInit.js"; -import { errorHandler } from "../errorHandler.js"; import { Credentials } from "../../types/Credentials.js"; +import { standardizeError } from "../standardizeError.js"; export const getQuestItems = async (credentials: Credentials) => { try { @@ -18,10 +18,6 @@ export const getQuestItems = async (credentials: Credentials) => { return questItems; } catch (error) { - return errorHandler({ - error, - functionName: "handleGetQuestItems", - message: "Error fetching Quest items", - }); + return standardizeError(error); } }; diff --git a/server/utils/droppedAssets/removeQuestItems.ts b/server/utils/droppedAssets/removeQuestItems.ts index 957b393..c92d77f 100644 --- a/server/utils/droppedAssets/removeQuestItems.ts +++ b/server/utils/droppedAssets/removeQuestItems.ts @@ -1,11 +1,14 @@ import { World } from "../topiaInit.js"; import { getQuestItems } from "./getQuestItems.js"; -import { errorHandler } from "../errorHandler.js"; import { Credentials } from "../../types/Credentials"; +import { standardizeError } from "../standardizeError.js"; export const removeQuestItems = async (credentials: Credentials) => { try { - const droppedAssets = await getQuestItems(credentials); + const getQuestItemsResponse = await getQuestItems(credentials); + if (getQuestItemsResponse instanceof Error) throw getQuestItemsResponse; + + const droppedAssets: Record = getQuestItemsResponse; if (Object.keys(droppedAssets).length > 0) { const droppedAssetIds = []; @@ -22,10 +25,6 @@ export const removeQuestItems = async (credentials: Credentials) => { return { success: true }; } catch (error) { - return errorHandler({ - error, - functionName: "removeQuestItems", - message: "Error removing Quest items", - }); + return standardizeError(error); } }; diff --git a/server/utils/getBadges.ts b/server/utils/getBadges.ts new file mode 100644 index 0000000..9e3d270 --- /dev/null +++ b/server/utils/getBadges.ts @@ -0,0 +1,34 @@ +import { Credentials } from "../types/Credentials.js"; +import { getCachedInventoryItems } from "./inventoryCache.js"; +import { standardizeError } from "./standardizeError.js"; + +export const getBadges = async (credentials: Credentials) => { + try { + const inventoryItems = await getCachedInventoryItems({ credentials }); + + const badges: { + [name: string]: { + id: string; + name: string; + icon: string; + description: string; + }; + } = {}; + + for (const item of inventoryItems) { + const { id, name, image_path, description, type, status } = item; + if (name && type === "BADGE" && status === "ACTIVE") { + badges[name] = { + id: id, + name, + icon: image_path || "", + description: description || "", + }; + } + } + + return badges; + } catch (error) { + return standardizeError(error); + } +}; diff --git a/server/utils/getDefaultKeyAssetImage.ts b/server/utils/getDefaultKeyAssetImage.ts index 115e9df..73ad93f 100644 --- a/server/utils/getDefaultKeyAssetImage.ts +++ b/server/utils/getDefaultKeyAssetImage.ts @@ -1,4 +1,4 @@ -import { errorHandler } from "./errorHandler.js"; +import { standardizeError } from "./standardizeError.js"; export const getDefaultKeyAssetImage = async (urlSlug: string) => { try { @@ -11,10 +11,6 @@ export const getDefaultKeyAssetImage = async (urlSlug: string) => { return questItemImage; } catch (error) { - return errorHandler({ - error, - functionName: "getDefaultKeyAssetImage", - message: "Error getting default key asset image", - }); + return standardizeError(error); } }; diff --git a/server/utils/getRandomCoordinates.ts b/server/utils/getRandomCoordinates.ts index 9cb45fb..8d402e6 100644 --- a/server/utils/getRandomCoordinates.ts +++ b/server/utils/getRandomCoordinates.ts @@ -1,4 +1,4 @@ -export const getRandomCoordinates = (width: number, height: number) => { +export const getRandomCoordinates = (width: number = 500, height: number = 500) => { const x = Math.floor(Math.random() * (width / 2 - -width / 2 + 1) + -width / 2); const y = Math.floor(Math.random() * (height / 2 - -height / 2 + 1) + -height / 2); return { x, y }; diff --git a/server/utils/index.ts b/server/utils/index.ts index 478c6a0..a5844e8 100644 --- a/server/utils/index.ts +++ b/server/utils/index.ts @@ -2,11 +2,15 @@ export * from "./droppedAssets/index.js"; export * from "./visitors/index.js"; export * from "./world/index.js"; export * from "./addNewRowToGoogleSheets.js"; +export * from "./awardBadge.js"; export * from "./cleanReturnPayload.js"; export * from "./errorHandler.js"; +export * from "./getBadges.js"; export * from "./getBaseURL.js"; export * from "./getCredentials.js"; export * from "./getDefaultKeyAssetImage.js"; export * from "./getDifferenceInDays.js"; export * from "./getRandomCoordinates.js"; +export * from "./inventoryCache.js"; +export * from "./standardizeError.js"; export * from "./topiaInit.js"; diff --git a/server/utils/inventoryCache.ts b/server/utils/inventoryCache.ts new file mode 100644 index 0000000..b64a919 --- /dev/null +++ b/server/utils/inventoryCache.ts @@ -0,0 +1,110 @@ +import { Credentials } from "../types"; +import { Ecosystem } from "./topiaInit.js"; +import { standardizeError } from "./standardizeError.js"; +import { InventoryItemInterface as BaseInventoryItemInterface } from "@rtsdk/topia"; + +// Extend InventoryItemInterface to include metadata with optional sortOrder +interface InventoryItemMetadata { + sortOrder?: number; + [key: string]: any; +} + +interface InventoryItemInterface extends BaseInventoryItemInterface { + metadata?: InventoryItemMetadata | null; +} + +interface CachedInventory { + items: InventoryItemInterface[]; + timestamp: number; +} + +// Cache duration: 24 hours in milliseconds +const CACHE_DURATION_MS = 24 * 60 * 60 * 1000; + +// In-memory cache +let inventoryCache: CachedInventory | null = null; + +/** + * Get ecosystem inventory items with caching + * - Fetches from cache if available and not expired + * - Refreshes cache if expired or missing + * - Can force refresh by passing forceRefresh: true + */ +export const getCachedInventoryItems = async ({ + credentials, + forceRefresh = false, +}: { + credentials: Credentials; + forceRefresh?: boolean; +}): Promise => { + try { + const now = Date.now(); + + // Check if cache is valid and not expired + const isCacheValid = inventoryCache !== null && !forceRefresh && now - inventoryCache.timestamp < CACHE_DURATION_MS; + + if (isCacheValid) { + return inventoryCache!.items; + } + + // Fetch fresh inventory items + console.log("Fetching fresh inventory items from ecosystem"); + const ecosystem = Ecosystem.create({ credentials }); + await ecosystem.fetchInventoryItems(); + + // Update cache + inventoryCache = { + items: (ecosystem.inventoryItems as InventoryItemInterface[]) + .map((item) => ({ + ...item, + metadata: { + ...(item.metadata || {}), + sortOrder: typeof item.metadata?.sortOrder === "number" ? item.metadata.sortOrder : 0, + }, + })) + .sort((a, b) => { + const aOrder = a.metadata?.sortOrder ?? 0; + const bOrder = b.metadata?.sortOrder ?? 0; + return aOrder - bOrder; + }), + timestamp: now, + }; + + return inventoryCache.items; + } catch (error) { + // If fetch fails but we have stale cache, return it as fallback + if (inventoryCache !== null) { + console.warn("Failed to fetch fresh inventory, using stale cache", error); + return inventoryCache.items; + } + + throw standardizeError(error); + } +}; + +/** + * Clear the inventory cache (useful for testing or forced refresh) + */ +export const clearInventoryCache = (): void => { + inventoryCache = null; + console.log("Inventory cache cleared"); +}; + +/** + * Get cache status for debugging + */ +export const getInventoryCacheStatus = (): { + isCached: boolean; + age?: number; + itemCount?: number; +} => { + if (inventoryCache === null) { + return { isCached: false }; + } + + return { + isCached: true, + age: Date.now() - inventoryCache.timestamp, + itemCount: inventoryCache.items.length, + }; +}; diff --git a/server/utils/standardizeError.ts b/server/utils/standardizeError.ts new file mode 100644 index 0000000..8723ffb --- /dev/null +++ b/server/utils/standardizeError.ts @@ -0,0 +1,27 @@ +/** + * Creates a standardized error object from various error types + * This helps provide consistent error formatting across the application + */ +export const standardizeError = (error: unknown): Error => { + // If error is already an Error instance, return it directly + if (error instanceof Error) { + return error; + } + + // If error is a string, create a new Error with that message + if (typeof error === "string") { + return new Error(error); + } + + // If error is an object, try to extract useful information + if (typeof error === "object" && error !== null) { + const message = (error as any).message || JSON.stringify(error); + const newError = new Error(message); + // Add original error as property for debugging + (newError as any).originalError = error; + return newError; + } + + // Fallback for other error types + return new Error(`Unknown error: ${String(error)}`); +}; diff --git a/server/utils/topiaInit.ts b/server/utils/topiaInit.ts index 9ff16db..9341d33 100644 --- a/server/utils/topiaInit.ts +++ b/server/utils/topiaInit.ts @@ -1,7 +1,15 @@ import dotenv from "dotenv"; dotenv.config({ path: "../.env" }); -import { Topia, AssetFactory, DroppedAssetFactory, UserFactory, VisitorFactory, WorldFactory } from "@rtsdk/topia"; +import { + Topia, + AssetFactory, + DroppedAssetFactory, + EcosystemFactory, + UserFactory, + VisitorFactory, + WorldFactory, +} from "@rtsdk/topia"; const config = { apiDomain: process.env.INSTANCE_DOMAIN || "api.topia.io", @@ -14,8 +22,9 @@ const myTopiaInstance = new Topia(config); const Asset = new AssetFactory(myTopiaInstance); const DroppedAsset = new DroppedAssetFactory(myTopiaInstance); +const Ecosystem = new EcosystemFactory(myTopiaInstance); const User = new UserFactory(myTopiaInstance); const Visitor = new VisitorFactory(myTopiaInstance); const World = new WorldFactory(myTopiaInstance); -export { Asset, DroppedAsset, myTopiaInstance, User, Visitor, World }; +export { Asset, DroppedAsset, Ecosystem, myTopiaInstance, User, Visitor, World }; diff --git a/server/utils/visitors/getVisitor.ts b/server/utils/visitors/getVisitor.ts index 341f3d2..cace78b 100644 --- a/server/utils/visitors/getVisitor.ts +++ b/server/utils/visitors/getVisitor.ts @@ -1,10 +1,20 @@ import { VisitorInterface } from "@rtsdk/topia"; import { Visitor } from "../topiaInit.js"; -import { errorHandler } from "../errorHandler.js"; import { Credentials } from "../../types/Credentials.js"; -import { UserDataObjectType } from "../../types/DataObjectTypes.js"; +import { UserDataObjectType, VisitorProgressType } from "../../types/DataObjectTypes.js"; +import { standardizeError } from "../standardizeError.js"; -export const getVisitor = async (credentials: Credentials, keyAssetId: string) => { +export const getVisitor = async ( + credentials: Credentials, + keyAssetId: string, +): Promise< + | { + visitor: VisitorInterface; + visitorProgress: VisitorProgressType; + visitorInventory: { [key: string]: { id: string; icon: string; name: string } }; + } + | Error +> => { try { const { urlSlug, visitorId } = credentials; const sceneDropId = credentials.sceneDropId || keyAssetId; @@ -40,12 +50,23 @@ export const getVisitor = async (credentials: Credentials, keyAssetId: string) = ); } - return { visitor, visitorProgress }; + await visitor.fetchInventoryItems(); + let visitorInventory: { [key: string]: { id: string; icon: string; name: string } } = {}; + + for (const item of visitor.inventoryItems) { + const { id, name = "", image_url, status, type } = item; + + if (status === "ACTIVE" && type === "BADGE") { + visitorInventory[name] = { + id, + icon: image_url, + name, + }; + } + } + + return { visitor, visitorProgress, visitorInventory }; } catch (error) { - return errorHandler({ - error, - functionName: "getVisitor", - message: "Error getting visitor", - }); + return standardizeError(error); } }; diff --git a/server/utils/world/getWorldDetails.ts b/server/utils/world/getWorldDetails.ts index 1dd3780..8884161 100644 --- a/server/utils/world/getWorldDetails.ts +++ b/server/utils/world/getWorldDetails.ts @@ -1,7 +1,8 @@ import { World } from "../topiaInit.js"; -import { errorHandler } from "../errorHandler.js"; import { initializeWorldDataObject } from "./initializeWorldDataObject.js"; import { Credentials, WorldDataObjectType } from "../../types/index.js"; +import { standardizeError } from "../standardizeError.js"; +import { WorldInterface } from "@rtsdk/topia"; type WorldDataObject = { scenes: { @@ -9,7 +10,14 @@ type WorldDataObject = { }; }; -export const getWorldDetails = async (credentials: Credentials, getDetails: boolean = true) => { +interface WorldType extends WorldInterface { + dataObject: WorldDataObject; +} + +export const getWorldDetails = async ( + credentials: Credentials, + getDetails: boolean = true, +): Promise<{ dataObject: WorldDataObjectType; world: WorldType } | Error> => { try { const { assetId, urlSlug } = credentials; const sceneDropId = credentials.sceneDropId || assetId; @@ -20,10 +28,12 @@ export const getWorldDetails = async (credentials: Credentials, getDetails: bool await initializeWorldDataObject({ credentials, world }); + // Ensure world.dataObject is defined and of correct type + if (!world.dataObject) world.dataObject = { scenes: {} }; const dataObject = world.dataObject as WorldDataObject; - return { dataObject: dataObject.scenes?.[sceneDropId], world }; + return { dataObject: dataObject.scenes?.[sceneDropId], world: world as WorldType }; } catch (error) { - return errorHandler({ error, functionName: "getWorldDetails", message: "Error getting world details" }); + return standardizeError(error); } }; diff --git a/server/utils/world/initializeWorldDataObject.ts b/server/utils/world/initializeWorldDataObject.ts index ad69a37..539f305 100644 --- a/server/utils/world/initializeWorldDataObject.ts +++ b/server/utils/world/initializeWorldDataObject.ts @@ -1,5 +1,5 @@ import { Credentials } from "../../types/Credentials.js"; -import { errorHandler } from "../errorHandler.js"; +import { standardizeError } from "../standardizeError.js"; import { DroppedAsset } from "../topiaInit.js"; export const initializeWorldDataObject = async ({ credentials, world }: { credentials: Credentials; world: any }) => { @@ -67,11 +67,7 @@ export const initializeWorldDataObject = async ({ credentials, world }: { creden await world.fetchDataObject(); return; } catch (error) { - errorHandler({ - error, - functionName: "initializeWorldDataObject", - message: "Error initializing world data object", - }); + standardizeError(error); return await world.fetchDataObject(); } };