diff --git a/.github/workflows/aws_auto_release.yml b/.github/workflows/aws_auto_release.yml index c265155..f536d63 100644 --- a/.github/workflows/aws_auto_release.yml +++ b/.github/workflows/aws_auto_release.yml @@ -8,7 +8,7 @@ on: concurrency: group: ${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true - + permissions: contents: write pull-requests: read @@ -30,10 +30,10 @@ jobs: 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..." @@ -46,21 +46,21 @@ jobs: 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 @@ -69,37 +69,37 @@ jobs: 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:" @@ -117,16 +117,16 @@ jobs: 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 @@ -137,13 +137,13 @@ jobs: 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 @@ -163,19 +163,19 @@ jobs: 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)) @@ -183,7 +183,7 @@ jobs: 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 @@ -191,23 +191,23 @@ jobs: 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" @@ -222,18 +222,18 @@ jobs: 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_dev_release.yml b/.github/workflows/aws_dev_release.yml index 46ddb74..602a41e 100644 --- a/.github/workflows/aws_dev_release.yml +++ b/.github/workflows/aws_dev_release.yml @@ -4,7 +4,7 @@ on: branches: - dev workflow_dispatch: - + env: REPOSITORY: 'sdk-example' ECS_Cluster: "topia-dev-sdk-apps" @@ -14,7 +14,7 @@ env: concurrency: group: ${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true - + permissions: id-token: write # This is required for requesting the JWT contents: read # This is required for actions/checkout @@ -26,7 +26,7 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - + - name: get servicename id: sername run: | @@ -42,27 +42,35 @@ jobs: echo "service=$service_value" >> "$GITHUB_OUTPUT" echo "Service value: $(echo $service_value | jq -c )" fi - + - uses: actions/setup-node@v4 with: node-version: 20.10 cache: 'npm' - run: npm i - run: CI=false npm run build - + - name: Set up QEMU uses: docker/setup-qemu-action@v3 - + with: + platforms: arm64,amd64 + - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::368076259134:role/github-actions-role aws-region: us-east-1 - + - name: Login to Amazon ECR id: login-ecr uses: aws-actions/amazon-ecr-login@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + buildkitd-flags: --debug + driver-opts: image=moby/buildkit:latest + - name: Image Metadata id: metadata uses: docker/metadata-action@v5 @@ -71,15 +79,23 @@ jobs: tags: | type=raw,value=${{ github.event.repository.name }} - - name: Build and tag - run: | - docker build --build-arg COMMIT_HASH=${{ github.sha }} --build-arg REF=${{ github.ref }} -t ${{ steps.metadata.outputs.tags }} . - - - name: push docker image to Amazon ECR - run: | - docker push ${{ steps.metadata.outputs.tags }} + - + - name: Build and push multi-platform images to ECR + uses: docker/build-push-action@v5 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.metadata.outputs.tags }} + build-args: | + REF=${{ github.ref }} + COMMIT_HASH=${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: false + sbom: false + deploy_matrix: runs-on: ubuntu-latest needs: Build @@ -96,4 +112,4 @@ jobs: - name: deploy run: | aws ecs update-service --cluster ${{ env.ECS_Cluster }} --service topia-${{ env.ENV }}-${{ matrix.service }}0 --force-new-deployment - + diff --git a/.github/workflows/aws_prod_release.yml b/.github/workflows/aws_prod_release.yml index 6f60adf..670d7b2 100644 --- a/.github/workflows/aws_prod_release.yml +++ b/.github/workflows/aws_prod_release.yml @@ -12,22 +12,22 @@ env: concurrency: group: ${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true - + permissions: id-token: write # This is required for requesting the JWT contents: read # This is required for actions/checkout - jobs: Build: runs-on: ubuntu-latest outputs: service: ${{ steps.sername.outputs.service }} steps: + - name: Checkout uses: actions/checkout@v4 with: ref: ${{ github.event.release.tag_name }} - + - name: get servicename id: sername run: | @@ -53,18 +53,18 @@ jobs: - 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 - + - name: Set up QEMU - uses: docker/setup-qemu-action@v3 + uses: docker/setup-qemu-action@v3.0.0 with: platforms: arm64,amd64 - + - name: Configure AWS credentials uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::368076259134:role/github-actions-role aws-region: us-east-1 - + - name: Login to Amazon ECR id: login-ecr uses: aws-actions/amazon-ecr-login@v2 @@ -98,7 +98,7 @@ jobs: cache-to: type=gha,mode=max provenance: false sbom: false - + deploy_matrix: runs-on: ubuntu-latest needs: Build @@ -114,4 +114,5 @@ jobs: - name: deploy run: | - aws ecs update-service --cluster ${{ env.ECS_Cluster }} --service topia-${{ env.ENV }}-${{ matrix.service }}0 --force-new-deployment \ No newline at end of file + aws ecs update-service --cluster ${{ env.ECS_Cluster }} --service topia-${{ env.ENV }}-${{ matrix.service }}0 --force-new-deployment + diff --git a/client/src/pages/Home.jsx b/client/src/pages/Home.jsx index 7433535..9c8a562 100644 --- a/client/src/pages/Home.jsx +++ b/client/src/pages/Home.jsx @@ -1,4 +1,5 @@ import { useContext, useState, useEffect } from "react"; +import { useSearchParams } from "react-router-dom"; // components import { @@ -22,13 +23,17 @@ import { backendAPI, loadGameState } from "@utils"; function Home() { const dispatch = useContext(GlobalDispatchContext); const { screenManager } = useContext(GlobalStateContext); + + const [searchParams] = useSearchParams(); + const forceRefreshInventory = searchParams.get("forceRefreshInventory") === "true"; + const [loading, setLoading] = useState(true); useEffect(() => { const fetchGameState = async () => { try { setLoading(true); - await loadGameState(dispatch); + await loadGameState(dispatch, forceRefreshInventory); } catch (error) { console.error("error in loadGameState action"); } finally { diff --git a/client/src/utils/loadGameState.js b/client/src/utils/loadGameState.js index 2127347..ab59e7c 100644 --- a/client/src/utils/loadGameState.js +++ b/client/src/utils/loadGameState.js @@ -1,9 +1,9 @@ import { backendAPI, getErrorMessage } from "@utils"; import { LOAD_GAME_STATE, SCREEN_MANAGER, SET_ERROR } from "@context/types"; -export const loadGameState = async (dispatch) => { +export const loadGameState = async (dispatch, forceRefreshInventory) => { try { - const result = await backendAPI?.post("/race/game-state"); + const result = await backendAPI?.post("/race/game-state", { forceRefreshInventory }); if (result?.data?.success) { const { checkpointsCompleted, diff --git a/server/controllers/handleLoadGameState.js b/server/controllers/handleLoadGameState.js index 295f27e..48f9662 100644 --- a/server/controllers/handleLoadGameState.js +++ b/server/controllers/handleLoadGameState.js @@ -12,6 +12,7 @@ export const handleLoadGameState = async (req, res) => { try { const credentials = getCredentials(req.query); const { profileId, urlSlug, sceneDropId } = credentials; + const forceRefresh = req.body?.forceRefreshInventory === true; const now = Date.now(); const world = await World.create(urlSlug, { credentials }); @@ -70,7 +71,7 @@ export const handleLoadGameState = async (req, res) => { const { visitor, visitorProgress, visitorInventory } = await getVisitor(credentials, true); let { checkpoints, startTimestamp } = visitorProgress; - const { badges } = await getInventoryItems(credentials); + const { badges } = await getInventoryItems(credentials, { forceRefresh }); return res.json({ checkpointsCompleted: checkpoints, diff --git a/server/utils/badges/awardBadge.js b/server/utils/badges/awardBadge.js index d36c9c4..fdc6c22 100644 --- a/server/utils/badges/awardBadge.js +++ b/server/utils/badges/awardBadge.js @@ -1,13 +1,11 @@ -import { Ecosystem } from "../topiaInit.js"; +import { getCachedInventoryItems } from "../inventoryCache.js"; export const awardBadge = async ({ credentials, visitor, visitorInventory, badgeName, redisObj, profileId }) => { 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); + const inventoryItems = await getCachedInventoryItems({ credentials }); + const inventoryItem = inventoryItems?.find((item) => item.name === badgeName); if (!inventoryItem) throw new Error(`Inventory item ${badgeName} not found in ecosystem`); await visitor.grantInventoryItem(inventoryItem, 1); diff --git a/server/utils/badges/getInventoryItems.js b/server/utils/badges/getInventoryItems.js index 55777a7..bd08e63 100644 --- a/server/utils/badges/getInventoryItems.js +++ b/server/utils/badges/getInventoryItems.js @@ -1,13 +1,11 @@ -import { Ecosystem } from "../index.js"; +import { getCachedInventoryItems } from "../inventoryCache.js"; -export const getInventoryItems = async (credentials) => { +export const getInventoryItems = async (credentials, { forceRefresh = false } = {}) => { try { - const ecosystem = await Ecosystem.create({ credentials }); - await ecosystem.fetchInventoryItems(); + const items = await getCachedInventoryItems({ credentials, forceRefresh }); const badges = {}; - - for (const item of ecosystem.inventoryItems) { + for (const item of items) { badges[item.name] = { id: item.id, name: item.name || "Unknown", @@ -16,18 +14,7 @@ export const getInventoryItems = async (credentials) => { }; } - // Sort items by sortOrder while keeping them as objects - const sortedBadges = {}; - - Object.values(badges) - .sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0)) - .forEach((badge) => { - sortedBadges[badge.name] = badge; - }); - - return { - badges: sortedBadges, - }; + return { badges }; } catch (error) { return standardizeError(error); } diff --git a/server/utils/inventoryCache.js b/server/utils/inventoryCache.js new file mode 100644 index 0000000..e3bd2d9 --- /dev/null +++ b/server/utils/inventoryCache.js @@ -0,0 +1,45 @@ +import { Ecosystem } from "./topiaInit.js"; + +// Cache duration: 6 hours in milliseconds +const CACHE_DURATION_MS = 6 * 60 * 60 * 1000; + +// In-memory cache +let inventoryCache = null; + +/** + * Get ecosystem inventory items with caching. + * - Returns cached data if available and not expired. + * - Refreshes cache if expired or missing. + * - Falls back to stale cache on API failure. + */ +export const getCachedInventoryItems = async ({ credentials, forceRefresh = false }) => { + try { + const now = Date.now(); + const isCacheValid = inventoryCache !== null && !forceRefresh && now - inventoryCache.timestamp < CACHE_DURATION_MS; + + if (isCacheValid) { + return inventoryCache.items; + } + + console.log("Fetching fresh inventory items from ecosystem"); + const ecosystem = await Ecosystem.create({ credentials }); + await ecosystem.fetchInventoryItems(); + + inventoryCache = { + items: [...ecosystem.inventoryItems].sort((a, b) => (a.metadata?.sortOrder ?? 0) - (b.metadata?.sortOrder ?? 0)), + timestamp: now, + }; + + return inventoryCache.items; + } catch (error) { + if (inventoryCache !== null) { + console.warn("Failed to fetch fresh inventory, using stale cache", error); + return inventoryCache.items; + } + throw error; + } +}; + +export const clearInventoryCache = () => { + inventoryCache = null; +};