From 0533ea4e3470a0599520953a09373c92068a13a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20D=C3=ADaz?= Date: Tue, 19 Nov 2024 15:19:48 -0300 Subject: [PATCH] feat: Add get all places endpoint (#560) * feat: Add task to fetch worlds live users * feat: Add endpoint to return merged Places and Worlds scene info * feat: Add tests * fix: Use max limit constant * feat: Add utils tests * feat: Add env var WORLDS_LIVE_DATA --- .../allPlacesWithAggregatedAttributes.ts | 450 ++++++++++++++++++ src/__data__/worldsLiveData.ts | 6 + ...ctive.test.ts => getAllPlacesList.test.ts} | 119 +++-- src/entities/Map/routes/getAllPlacesList.ts | 99 ++++ .../Map/routes/getAllPlacesMostActiveList.ts | 114 +++++ src/entities/Map/routes/index.ts | 3 + src/entities/Map/schemas.ts | 80 ++++ src/entities/Map/types.ts | 18 +- src/entities/Map/utils.test.ts | 84 +++- src/entities/Map/utils.ts | 55 ++- src/entities/Place/model.ts | 190 ++++++++ src/entities/World/tasks/worldsLiveData.ts | 11 + src/entities/World/types.ts | 10 + src/entities/World/utils.ts | 25 +- src/server.ts | 2 + 15 files changed, 1211 insertions(+), 55 deletions(-) create mode 100644 src/__data__/allPlacesWithAggregatedAttributes.ts create mode 100644 src/__data__/worldsLiveData.ts rename src/entities/Map/routes/{getMapPlacesMostActive.test.ts => getAllPlacesList.test.ts} (57%) create mode 100644 src/entities/Map/routes/getAllPlacesList.ts create mode 100644 src/entities/Map/routes/getAllPlacesMostActiveList.ts create mode 100644 src/entities/World/tasks/worldsLiveData.ts diff --git a/src/__data__/allPlacesWithAggregatedAttributes.ts b/src/__data__/allPlacesWithAggregatedAttributes.ts new file mode 100644 index 00000000..6980b819 --- /dev/null +++ b/src/__data__/allPlacesWithAggregatedAttributes.ts @@ -0,0 +1,450 @@ +import { SceneContentRating } from "decentraland-gatsby/dist/utils/api/Catalyst.types" + +import { AggregatePlaceAttributes } from "../entities/Place/types" + +export const allPlacesWithAggregatedAttributes: AggregatePlaceAttributes[] = [ + { + id: "214d5f61-4049-4109-b811-690813b1f7f0", + title: "Genesis Plaza", + description: + "Jump in to strike up a chat with other visitors, retake the commands tutorial with a cute floating robot, or dive into the swirling portal to get to Decentraland's visitor center.", + image: "https://localhost:8000/images/places/genesis_plaza.jpg", + owner: null, + positions: [ + "-1,-1", + "-1,-2", + "-1,-3", + "-1,-4", + "-1,-5", + "-1,-6", + "-1,-7", + "-1,-8", + "-1,-9", + "-1,0", + "-1,1", + "-1,2", + "-1,3", + "-1,4", + "-1,5", + "-1,6", + "-1,7", + "-1,8", + "-1,9", + "-2,-1", + "-2,-2", + "-2,-3", + "-2,-4", + "-2,-5", + "-2,-6", + "-2,-7", + "-2,-8", + "-2,-9", + "-2,0", + "-2,1", + "-2,2", + "-2,3", + "-2,4", + "-2,5", + "-2,6", + "-2,7", + "-2,8", + "-2,9", + "-3,-1", + "-3,-2", + "-3,-3", + "-3,-4", + "-3,-5", + "-3,-6", + "-3,-7", + "-3,-8", + "-3,-9", + "-3,0", + "-3,1", + "-3,2", + "-3,3", + "-3,4", + "-3,5", + "-3,6", + "-3,7", + "-3,8", + "-3,9", + "-4,-1", + "-4,-2", + "-4,-3", + "-4,-4", + "-4,-5", + "-4,-6", + "-4,-7", + "-4,-8", + "-4,-9", + "-4,0", + "-4,1", + "-4,2", + "-4,3", + "-4,4", + "-4,5", + "-4,6", + "-4,7", + "-4,8", + "-4,9", + "-5,-1", + "-5,-2", + "-5,-3", + "-5,-4", + "-5,-5", + "-5,-6", + "-5,-7", + "-5,-8", + "-5,-9", + "-5,0", + "-5,1", + "-5,2", + "-5,3", + "-5,4", + "-5,5", + "-5,6", + "-5,7", + "-5,8", + "-5,9", + "-6,-1", + "-6,-2", + "-6,-3", + "-6,-4", + "-6,-5", + "-6,-6", + "-6,-7", + "-6,-8", + "-6,-9", + "-6,0", + "-6,1", + "-6,2", + "-6,3", + "-6,4", + "-6,5", + "-6,6", + "-6,7", + "-6,8", + "-6,9", + "-7,-1", + "-7,-2", + "-7,-3", + "-7,-4", + "-7,-5", + "-7,-6", + "-7,-7", + "-7,-8", + "-7,-9", + "-7,0", + "-7,1", + "-7,2", + "-7,3", + "-7,4", + "-7,5", + "-7,6", + "-7,7", + "-7,8", + "-7,9", + "-8,-1", + "-8,-2", + "-8,-3", + "-8,-4", + "-8,-5", + "-8,-6", + "-8,-7", + "-8,-8", + "-8,-9", + "-8,0", + "-8,1", + "-8,2", + "-8,3", + "-8,4", + "-8,5", + "-8,6", + "-8,7", + "-8,8", + "-8,9", + "-9,-1", + "-9,-2", + "-9,-3", + "-9,-4", + "-9,-5", + "-9,-6", + "-9,-7", + "-9,-8", + "-9,-9", + "-9,0", + "-9,1", + "-9,2", + "-9,3", + "-9,4", + "-9,5", + "-9,6", + "-9,7", + "-9,8", + "-9,9", + "0,-1", + "0,-2", + "0,-3", + "0,-4", + "0,-5", + "0,-6", + "0,-7", + "0,-8", + "0,-9", + "0,0", + "0,1", + "0,2", + "0,3", + "0,4", + "0,5", + "0,6", + "0,7", + "0,8", + "0,9", + "1,-1", + "1,-2", + "1,-3", + "1,-4", + "1,-5", + "1,-6", + "1,-7", + "1,-8", + "1,-9", + "1,0", + "1,1", + "1,2", + "1,3", + "1,4", + "1,5", + "1,6", + "1,7", + "1,8", + "1,9", + "10,-1", + "10,-2", + "10,-3", + "10,-4", + "10,-5", + "10,-6", + "10,-7", + "10,-8", + "10,-9", + "10,0", + "10,1", + "10,2", + "10,3", + "10,4", + "10,5", + "10,6", + "10,7", + "10,8", + "10,9", + "2,-1", + "2,-2", + "2,-3", + "2,-4", + "2,-5", + "2,-6", + "2,-7", + "2,-8", + "2,-9", + "2,0", + "2,1", + "2,2", + "2,3", + "2,4", + "2,5", + "2,6", + "2,7", + "2,8", + "2,9", + "3,-1", + "3,-2", + "3,-3", + "3,-4", + "3,-5", + "3,-6", + "3,-7", + "3,-8", + "3,-9", + "3,0", + "3,1", + "3,2", + "3,3", + "3,4", + "3,5", + "3,6", + "3,7", + "3,8", + "3,9", + "4,-1", + "4,-2", + "4,-3", + "4,-4", + "4,-5", + "4,-6", + "4,-7", + "4,-8", + "4,-9", + "4,0", + "4,1", + "4,2", + "4,3", + "4,4", + "4,5", + "4,6", + "4,7", + "4,8", + "4,9", + "5,-1", + "5,-2", + "5,-3", + "5,-4", + "5,-5", + "5,-6", + "5,-7", + "5,-8", + "5,-9", + "5,0", + "5,1", + "5,2", + "5,3", + "5,4", + "5,5", + "5,6", + "5,7", + "5,8", + "5,9", + "6,-1", + "6,-2", + "6,-3", + "6,-4", + "6,-5", + "6,-6", + "6,-7", + "6,-8", + "6,-9", + "6,0", + "6,1", + "6,2", + "6,3", + "6,4", + "6,5", + "6,6", + "6,7", + "6,8", + "6,9", + "7,-1", + "7,-2", + "7,-3", + "7,-4", + "7,-5", + "7,-6", + "7,-7", + "7,-8", + "7,-9", + "7,0", + "7,1", + "7,2", + "7,3", + "7,4", + "7,5", + "7,6", + "7,7", + "7,8", + "7,9", + "8,-1", + "8,-2", + "8,-3", + "8,-4", + "8,-5", + "8,-6", + "8,-7", + "8,-8", + "8,-9", + "8,0", + "8,1", + "8,2", + "8,3", + "8,4", + "8,5", + "8,6", + "8,7", + "8,8", + "8,9", + "9,-1", + "9,-2", + "9,-3", + "9,-4", + "9,-5", + "9,-6", + "9,-7", + "9,-8", + "9,-9", + "9,0", + "9,1", + "9,2", + "9,3", + "9,4", + "9,5", + "9,6", + "9,7", + "9,8", + "9,9", + ], + base_position: "-9,-9", + contact_name: "Decentraland Foundation", + contact_email: null, + content_rating: SceneContentRating.RATING_PENDING, + disabled: false, + disabled_at: null, + created_at: new Date("2022-11-11T04:53:07.000Z"), + updated_at: new Date("2022-11-11T04:53:07.000Z"), + favorites: 0, + likes: 0, + dislikes: 0, + like_rate: 0.5, + like_score: 0, + highlighted: true, + highlighted_image: "/images/places/genesis_plaza_banner.jpg", + user_favorite: false, + user_like: false, + user_dislike: false, + categories: [], + world: false, + world_name: null, + deployed_at: new Date("2022-11-14T17:22:05.307Z"), + textsearch: undefined, + }, + { + id: "a7ce87fa-df3c-4a2f-bca6-bd2fe794d51a", + title: "Test World", + description: "Test World", + image: "https://localhost:8000/images/places/genesis_plaza.jpg", + owner: null, + positions: ["0,0"], + base_position: "0,0", + contact_name: "Decentraland Foundation", + contact_email: null, + content_rating: SceneContentRating.RATING_PENDING, + disabled: false, + disabled_at: null, + created_at: new Date("2022-11-11T04:53:07.000Z"), + updated_at: new Date("2022-11-11T04:53:07.000Z"), + favorites: 0, + likes: 0, + dislikes: 0, + like_rate: 0.6, + like_score: 0, + highlighted: true, + highlighted_image: "/images/places/genesis_plaza_banner.jpg", + user_favorite: false, + user_like: false, + user_dislike: false, + categories: [], + world: true, + world_name: "test.dcl.eth", + deployed_at: new Date("2022-11-14T17:22:05.307Z"), + textsearch: undefined, + }, +] diff --git a/src/__data__/worldsLiveData.ts b/src/__data__/worldsLiveData.ts new file mode 100644 index 00000000..74178f30 --- /dev/null +++ b/src/__data__/worldsLiveData.ts @@ -0,0 +1,6 @@ +import { WorldLiveDataProps } from "../entities/World/types" + +export const worldsLiveData: WorldLiveDataProps = { + perWorld: [{ worldName: "test.dcl.eth", users: 30 }], + totalUsers: 30, +} diff --git a/src/entities/Map/routes/getMapPlacesMostActive.test.ts b/src/entities/Map/routes/getAllPlacesList.test.ts similarity index 57% rename from src/entities/Map/routes/getMapPlacesMostActive.test.ts rename to src/entities/Map/routes/getAllPlacesList.test.ts index e7b45f25..2fd1c2ae 100644 --- a/src/entities/Map/routes/getMapPlacesMostActive.test.ts +++ b/src/entities/Map/routes/getAllPlacesList.test.ts @@ -1,70 +1,79 @@ import { Request } from "decentraland-gatsby/dist/entities/Route/wkc/request/Request" +import { allPlacesWithAggregatedAttributes } from "../../../__data__/allPlacesWithAggregatedAttributes" import { hotSceneGenesisPlaza } from "../../../__data__/hotSceneGenesisPlaza" -import { placeGenesisPlazaWithAggregatedAttributes } from "../../../__data__/placeGenesisPlazaWithAggregatedAttributes" import { sceneStatsGenesisPlaza } from "../../../__data__/sceneStatsGenesisPlaza" +import { worldsLiveData } from "../../../__data__/worldsLiveData" import PlaceModel from "../../Place/model" import * as hotScenes from "../../RealmProvider/utils" import * as sceneStats from "../../SceneStats/utils" -import { getMapPlaces } from "./getMapPlaces" +import * as worldsUtils from "../../World/utils" +import { getAllPlacesList } from "./getAllPlacesList" const find = jest.spyOn(PlaceModel, "namedQuery") const catalystHotScenes = jest.spyOn(hotScenes, "getHotScenes") const catalystSceneStats = jest.spyOn(sceneStats, "getSceneStats") +const worldsContentServerLiveData = jest.spyOn(worldsUtils, "getWorldsLiveData") afterEach(() => { find.mockReset() catalystHotScenes.mockReset() catalystSceneStats.mockReset() + worldsContentServerLiveData.mockReset() }) -test("should return a object of places with no query", async () => { - find.mockResolvedValueOnce( - Promise.resolve([placeGenesisPlazaWithAggregatedAttributes]) - ) - find.mockResolvedValueOnce(Promise.resolve([{ total: 1 }])) +test("should return a list of places with no query", async () => { + find.mockResolvedValueOnce(Promise.resolve(allPlacesWithAggregatedAttributes)) + find.mockResolvedValueOnce(Promise.resolve([{ total: 2 }])) catalystHotScenes.mockReturnValueOnce([hotSceneGenesisPlaza]) catalystSceneStats.mockResolvedValueOnce( Promise.resolve(sceneStatsGenesisPlaza) ) + worldsContentServerLiveData.mockReturnValueOnce(worldsLiveData) const request = new Request("/") const url = new URL("https://localhost/") - const placeResponse = await getMapPlaces({ + const placeResponse = await getAllPlacesList({ request, url, }) expect(placeResponse.body).toEqual({ ok: true, - total: 1, - data: { - ["-9,-9"]: { - ...placeGenesisPlazaWithAggregatedAttributes, + total: 2, + data: [ + { + ...allPlacesWithAggregatedAttributes[0], user_count: hotSceneGenesisPlaza.usersTotalCount, user_visits: sceneStatsGenesisPlaza["-9,-9"].last_30d.users, - positions: undefined, }, - }, + { + ...allPlacesWithAggregatedAttributes[1], + user_count: worldsLiveData.perWorld[0].users, + // TODO: Get user visits from world stats + user_visits: 0, + }, + ], }) expect(find.mock.calls.length).toBe(2) expect(catalystHotScenes.mock.calls.length).toBe(1) expect(catalystSceneStats.mock.calls.length).toBe(1) + expect(worldsContentServerLiveData.mock.calls.length).toBe(1) }) -test("should return a object of places with query", async () => { +test("should return a list of places with query", async () => { find.mockResolvedValueOnce( - Promise.resolve([placeGenesisPlazaWithAggregatedAttributes]) + Promise.resolve(allPlacesWithAggregatedAttributes.slice(0, 1)) ) find.mockResolvedValueOnce(Promise.resolve([{ total: 1 }])) catalystHotScenes.mockReturnValueOnce([hotSceneGenesisPlaza]) - catalystSceneStats.mockResolvedValueOnce( Promise.resolve(sceneStatsGenesisPlaza) ) + worldsContentServerLiveData.mockReturnValueOnce(worldsLiveData) const request = new Request("/") const url = new URL( "https://localhost/?position=-9,-9&limit=1&offset=1&order_by=like_rate&order=asc" ) - const placeResponse = await getMapPlaces({ + const placeResponse = await getAllPlacesList({ request, url, }) @@ -72,91 +81,99 @@ test("should return a object of places with query", async () => { expect(placeResponse.body).toEqual({ ok: true, total: 1, - data: { - ["-9,-9"]: { - ...placeGenesisPlazaWithAggregatedAttributes, + data: [ + { + ...allPlacesWithAggregatedAttributes[0], user_count: hotSceneGenesisPlaza.usersTotalCount, user_visits: sceneStatsGenesisPlaza["-9,-9"].last_30d.users, - positions: undefined, }, - }, + ], }) expect(find.mock.calls.length).toBe(2) expect(catalystHotScenes.mock.calls.length).toBe(1) expect(catalystSceneStats.mock.calls.length).toBe(1) + expect(worldsContentServerLiveData.mock.calls.length).toBe(1) }) -test("should return a object of places with order by most_active", async () => { - find.mockResolvedValueOnce( - Promise.resolve([placeGenesisPlazaWithAggregatedAttributes]) - ) +test("should return a list of places with order by most_active", async () => { + find.mockResolvedValueOnce(Promise.resolve(allPlacesWithAggregatedAttributes)) catalystHotScenes.mockReturnValueOnce([hotSceneGenesisPlaza]) - catalystSceneStats.mockResolvedValueOnce( Promise.resolve(sceneStatsGenesisPlaza) ) + worldsContentServerLiveData.mockReturnValueOnce(worldsLiveData) const request = new Request("/") - const url = new URL("https://localhost/?&order_by=most_active&limit=1") - const placeResponse = await getMapPlaces({ + const url = new URL("https://localhost/?&order_by=most_active") + const placeResponse = await getAllPlacesList({ request, url, }) expect(placeResponse.body).toEqual({ ok: true, - total: 1, - data: { - ["-9,-9"]: { - ...placeGenesisPlazaWithAggregatedAttributes, + total: 2, + data: [ + { + ...allPlacesWithAggregatedAttributes[1], + user_count: worldsLiveData.perWorld[0].users, + // TODO: Get user visits from world stats + user_visits: 0, + }, + { + ...allPlacesWithAggregatedAttributes[0], user_count: hotSceneGenesisPlaza.usersTotalCount, user_visits: sceneStatsGenesisPlaza["-9,-9"].last_30d.users, - positions: undefined, }, - }, + ], }) expect(find.mock.calls.length).toBe(1) expect(catalystHotScenes.mock.calls.length).toBe(1) expect(catalystSceneStats.mock.calls.length).toBe(1) + expect(worldsContentServerLiveData.mock.calls.length).toBe(1) }) -test("should return a object of places with Realm details", async () => { - find.mockResolvedValueOnce( - Promise.resolve([placeGenesisPlazaWithAggregatedAttributes]) - ) - find.mockResolvedValueOnce(Promise.resolve([{ total: 1 }])) +test("should return a list of places with Realm details", async () => { + find.mockResolvedValueOnce(Promise.resolve(allPlacesWithAggregatedAttributes)) + find.mockResolvedValueOnce(Promise.resolve([{ total: 2 }])) catalystHotScenes.mockReturnValueOnce([hotSceneGenesisPlaza]) - catalystSceneStats.mockResolvedValueOnce( Promise.resolve(sceneStatsGenesisPlaza) ) + worldsContentServerLiveData.mockReturnValueOnce(worldsLiveData) const request = new Request("/") const url = new URL("https://localhost/?with_realms_detail=true") - const placeResponse = await getMapPlaces({ + const placeResponse = await getAllPlacesList({ request, url, }) expect(placeResponse.body).toEqual({ ok: true, - total: 1, - data: { - ["-9,-9"]: { - ...placeGenesisPlazaWithAggregatedAttributes, + total: 2, + data: [ + { + ...allPlacesWithAggregatedAttributes[0], user_count: hotSceneGenesisPlaza.usersTotalCount, user_visits: sceneStatsGenesisPlaza["-9,-9"].last_30d.users, realms_detail: hotSceneGenesisPlaza.realms, - positions: undefined, }, - }, + { + ...allPlacesWithAggregatedAttributes[1], + user_count: worldsLiveData.perWorld[0].users, + // TODO: Get user visits from world stats + user_visits: 0, + }, + ], }) expect(find.mock.calls.length).toBe(2) expect(catalystHotScenes.mock.calls.length).toBe(1) expect(catalystSceneStats.mock.calls.length).toBe(1) + expect(worldsContentServerLiveData.mock.calls.length).toBe(1) }) test("should return 0 as total list when query onlyFavorites with no auth", async () => { const request = new Request("/") const url = new URL("https://localhost/?only_favorites=true") - const placeResponse = await getMapPlaces({ + const placeResponse = await getAllPlacesList({ request, url, }) @@ -174,7 +191,7 @@ test("should return an error when a wrong value has been sent in the query", asy const url = new URL("https://localhost/?order_by=fake") expect(async () => - getMapPlaces({ + getAllPlacesList({ request, url, }) diff --git a/src/entities/Map/routes/getAllPlacesList.ts b/src/entities/Map/routes/getAllPlacesList.ts new file mode 100644 index 00000000..c2d2886a --- /dev/null +++ b/src/entities/Map/routes/getAllPlacesList.ts @@ -0,0 +1,99 @@ +import { withAuthOptional } from "decentraland-gatsby/dist/entities/Auth/routes/withDecentralandAuth" +import Context from "decentraland-gatsby/dist/entities/Route/wkc/context/Context" +import ApiResponse from "decentraland-gatsby/dist/entities/Route/wkc/response/ApiResponse" +import Router from "decentraland-gatsby/dist/entities/Route/wkc/routes/Router" +import { + bool, + numeric, + oneOf, +} from "decentraland-gatsby/dist/entities/Schema/utils" + +import PlaceModel from "../../Place/model" +import { PlaceListOrderBy } from "../../Place/types" +import { getHotScenes } from "../../RealmProvider/utils" +import { getSceneStats } from "../../SceneStats/utils" +import { getWorldsLiveData } from "../../World/utils" +import { getAllPlacesListQuerySchema } from "../schemas" +import { + DEFAULT_MAX_LIMIT, + FindAllPlacesWithAggregatesOptions, + GetAllPlaceListQuery, +} from "../types" +import { allPlacesWithAggregates } from "../utils" +import { getAllPlacesMostActiveList } from "./getAllPlacesMostActiveList" + +export const validateGetPlaceListQuery = Router.validator( + getAllPlacesListQuerySchema +) + +export const getAllPlacesList = Router.memo( + async (ctx: Context<{}, "url" | "request">) => { + if (ctx.url.searchParams.get("order_by") === PlaceListOrderBy.MOST_ACTIVE) { + return getAllPlacesMostActiveList(ctx) + } + + const query = await validateGetPlaceListQuery({ + offset: ctx.url.searchParams.get("offset"), + limit: ctx.url.searchParams.get("limit"), + only_favorites: ctx.url.searchParams.get("only_favorites"), + only_featured: ctx.url.searchParams.get("only_featured"), + only_highlighted: ctx.url.searchParams.get("only_highlighted"), + positions: ctx.url.searchParams.getAll("positions"), + names: ctx.url.searchParams.getAll("names"), + order_by: + oneOf(ctx.url.searchParams.get("order_by"), [ + PlaceListOrderBy.LIKE_SCORE_BEST, + PlaceListOrderBy.UPDATED_AT, + PlaceListOrderBy.CREATED_AT, + ]) || PlaceListOrderBy.LIKE_SCORE_BEST, + order: + oneOf(ctx.url.searchParams.get("order"), ["asc", "desc"]) || "desc", + with_realms_detail: ctx.url.searchParams.get("with_realms_detail"), + search: ctx.url.searchParams.get("search"), + categories: ctx.url.searchParams.getAll("categories"), + }) + + const userAuth = await withAuthOptional(ctx) + + if (bool(query.only_favorites) && !userAuth?.address) { + return new ApiResponse([], { total: 0 }) + } + + const options: FindAllPlacesWithAggregatesOptions = { + user: userAuth?.address, + offset: numeric(query.offset, { min: 0 }) ?? 0, + limit: + numeric(query.limit, { min: 0, max: DEFAULT_MAX_LIMIT }) ?? + DEFAULT_MAX_LIMIT, + only_favorites: !!bool(query.only_favorites), + only_highlighted: !!bool(query.only_highlighted), + positions: query.positions, + names: query.names, + order_by: query.order_by, + order: query.order, + search: query.search, + categories: query.categories, + } + + const hotScenes = getHotScenes() + const worldsLiveData = getWorldsLiveData() + + const [data, total, sceneStats] = await Promise.all([ + PlaceModel.findAllPlacesWithAggregates(options), + PlaceModel.countAllPlaces(options), + getSceneStats(), + ]) + + const response = allPlacesWithAggregates( + data, + hotScenes, + sceneStats, + worldsLiveData, + { + withRealmsDetail: !!bool(query.with_realms_detail), + } + ) + + return new ApiResponse(response, { total }) + } +) diff --git a/src/entities/Map/routes/getAllPlacesMostActiveList.ts b/src/entities/Map/routes/getAllPlacesMostActiveList.ts new file mode 100644 index 00000000..d5766e10 --- /dev/null +++ b/src/entities/Map/routes/getAllPlacesMostActiveList.ts @@ -0,0 +1,114 @@ +import { withAuthOptional } from "decentraland-gatsby/dist/entities/Auth/routes/withDecentralandAuth" +import Context from "decentraland-gatsby/dist/entities/Route/wkc/context/Context" +import ApiResponse from "decentraland-gatsby/dist/entities/Route/wkc/response/ApiResponse" +import Router from "decentraland-gatsby/dist/entities/Route/wkc/routes/Router" +import { + bool, + numeric, + oneOf, +} from "decentraland-gatsby/dist/entities/Schema/utils" +import { flat, sort } from "radash" + +import PlaceModel from "../../Place/model" +import { PlaceListOrderBy } from "../../Place/types" +import { getHotScenes } from "../../RealmProvider/utils" +import { getSceneStats } from "../../SceneStats/utils" +import { getWorldsLiveData } from "../../World/utils" +import { DEFAULT_MAX_LIMIT, FindAllPlacesWithAggregatesOptions } from "../types" +import { allPlacesWithAggregates } from "../utils" +import { validateGetPlaceListQuery } from "./getAllPlacesList" + +export const getAllPlacesMostActiveList = Router.memo( + async (ctx: Context<{}, "url" | "request">) => { + const query = await validateGetPlaceListQuery({ + offset: ctx.url.searchParams.get("offset"), + limit: ctx.url.searchParams.get("limit"), + only_favorites: ctx.url.searchParams.get("only_favorites"), + only_featured: ctx.url.searchParams.get("only_featured"), + only_highlighted: ctx.url.searchParams.get("only_highlighted"), + positions: ctx.url.searchParams.getAll("positions"), + names: ctx.url.searchParams.getAll("names"), + order_by: PlaceListOrderBy.MOST_ACTIVE, + order: + oneOf(ctx.url.searchParams.get("order"), ["asc", "desc"]) || "desc", + with_realms_detail: ctx.url.searchParams.get("with_realms_detail"), + search: ctx.url.searchParams.get("search"), + categories: ctx.url.searchParams.getAll("categories"), + }) + + const hotScenes = getHotScenes() + + const userAuth = await withAuthOptional(ctx) + + if ( + (bool(query.only_favorites) && !userAuth?.address) || + (numeric(query.offset) ?? 0) > hotScenes.length + ) { + return new ApiResponse([], { total: 0 }) + } + + const sceneStats = await getSceneStats() + + // TODO: Get Worlds stats + // const worldsStats = await getWorldsStats() + + const worldsLiveData = getWorldsLiveData() + + const hotScenesParcels = hotScenes.map((scene) => scene.parcels) + + const hotScenesPositions = flat(hotScenesParcels).map((scene) => + scene.join(",") + ) + + const positions = new Set(query.positions) + + const hotWorldsNames = worldsLiveData?.perWorld + ? worldsLiveData.perWorld.map((world) => world.worldName) + : [] + + const names = new Set(query.names) + + const options: FindAllPlacesWithAggregatesOptions = { + user: userAuth?.address, + offset: numeric(query.offset, { min: 0 }) ?? 0, + limit: + numeric(query.limit, { min: 0, max: DEFAULT_MAX_LIMIT }) ?? + DEFAULT_MAX_LIMIT, + only_favorites: !!bool(query.only_favorites), + only_highlighted: !!bool(query.only_highlighted), + positions: query.positions.length + ? hotScenesPositions.filter((position) => positions.has(position)) + : hotScenesPositions, + names: query.names.length + ? query.names.filter((name) => names.has(name)) + : hotWorldsNames, + order_by: PlaceListOrderBy.MOST_ACTIVE, + order: query.order, + search: query.search, + categories: query.categories, + } + + const places = await PlaceModel.findAllPlacesWithAggregates(options) + + const hotScenePlaces = sort( + allPlacesWithAggregates(places, hotScenes, sceneStats, worldsLiveData, { + withRealmsDetail: !!bool(query.with_realms_detail), + }), + (place) => place.user_count || 0, + !options.order || options.order === "desc" + ) + + const total = hotScenePlaces.length + + const from = numeric(options.offset || 0, { min: 0 }) ?? 0 + const to = + numeric(from + (options.limit || DEFAULT_MAX_LIMIT), { + min: 0, + max: DEFAULT_MAX_LIMIT, + }) ?? DEFAULT_MAX_LIMIT + + return new ApiResponse(hotScenePlaces.slice(from, to), { + total, + }) + } +) diff --git a/src/entities/Map/routes/index.ts b/src/entities/Map/routes/index.ts index 1e9d4d89..e6730f1e 100644 --- a/src/entities/Map/routes/index.ts +++ b/src/entities/Map/routes/index.ts @@ -2,6 +2,7 @@ import withCors from "decentraland-gatsby/dist/entities/Route/middleware/withCor import routes from "decentraland-gatsby/dist/entities/Route/wkc/routes" import env from "decentraland-gatsby/dist/utils/env" +import { getAllPlacesList } from "./getAllPlacesList" import { getMapPlaces } from "./getMapPlaces" export const DECENTRALAND_URL = env("DECENTRALAND_URL", "") @@ -24,4 +25,6 @@ export default routes((router) => { }) ) router.get("/map", getMapPlaces) + // This new endpoint will merge the places and worlds content + router.get("/map/places", getAllPlacesList) }, {}) diff --git a/src/entities/Map/schemas.ts b/src/entities/Map/schemas.ts index fa248a24..12065fc8 100644 --- a/src/entities/Map/schemas.ts +++ b/src/entities/Map/schemas.ts @@ -1,6 +1,7 @@ import schema from "decentraland-gatsby/dist/entities/Schema/schema" import { realmSchema } from "../Place/schemas" +import { PlaceListOrderBy } from "../Place/types" export const mapPlaceSchema = schema({ type: "object", @@ -83,3 +84,82 @@ export const mapPlaceSchema = schema({ }) export const mapPlaceResponseSchema = schema.api(mapPlaceSchema) + +export const getAllPlacesListQuerySchema = schema({ + type: "object", + required: [], + properties: { + limit: { + type: "string", + format: "uint", + nullable: true as any, + }, + offset: { + type: "string", + format: "uint", + nullable: true as any, + }, + positions: { + type: "array", + maxItems: 1000, + items: { type: "string", pattern: "^-?\\d{1,3},-?\\d{1,3}$" }, + description: "Filter places in specific positions", + nullable: true as any, + }, + names: { + type: "array", + maxItems: 1000, + items: { type: "string" }, + description: "Filter worlds by names", + nullable: true as any, + }, + only_favorites: { + type: "string", + format: "boolean", + description: "True if shows only favorite places", + nullable: true as any, + }, + only_highlighted: { + type: "string", + format: "boolean", + description: "True if shows only highlighted places", + nullable: true as any, + }, + order_by: { + type: "string", + description: "Order places by", + enum: [ + PlaceListOrderBy.LIKE_SCORE_BEST, + PlaceListOrderBy.MOST_ACTIVE, + PlaceListOrderBy.UPDATED_AT, + PlaceListOrderBy.CREATED_AT, + ], + nullable: true as any, + }, + order: { + type: "string", + description: "List order", + default: "desc", + enum: ["asc", "desc"], + nullable: true as any, + }, + with_realms_detail: { + type: "string", + format: "boolean", + description: "Add the numbers of users in each Realm (experimental)", + nullable: true as any, + }, + search: { + type: "string", + description: + "Filter places that contains a text expression, should have at least 3 characters otherwise the resultant list will be empty", + nullable: true as any, + }, + categories: { + type: "array", + items: { type: "string" }, + description: "Filter places by available categories", + nullable: true as any, + }, + }, +}) diff --git a/src/entities/Map/types.ts b/src/entities/Map/types.ts index bbf5acc2..f97f822f 100644 --- a/src/entities/Map/types.ts +++ b/src/entities/Map/types.ts @@ -1,4 +1,8 @@ -import { AggregatePlaceAttributes } from "../Place/types" +import { + AggregatePlaceAttributes, + GetPlaceListQuery, + PlaceListOptions, +} from "../Place/types" export type AggregateCoordinatePlaceAttributes = Pick< AggregatePlaceAttributes, @@ -19,4 +23,16 @@ export type AggregateCoordinatePlaceAttributes = Pick< positions?: string[] } +export type GetAllPlaceListQuery = GetPlaceListQuery & { + names: string[] +} + +export type AllPlacesListOptions = PlaceListOptions & { + names: string[] +} + +export type FindAllPlacesWithAggregatesOptions = AllPlacesListOptions & { + user?: string +} + export const DEFAULT_MAX_LIMIT = 500 diff --git a/src/entities/Map/utils.test.ts b/src/entities/Map/utils.test.ts index ed6b3882..53ca49c7 100644 --- a/src/entities/Map/utils.test.ts +++ b/src/entities/Map/utils.test.ts @@ -1,7 +1,12 @@ +import { allPlacesWithAggregatedAttributes } from "../../__data__/allPlacesWithAggregatedAttributes" import { hotSceneGenesisPlaza } from "../../__data__/hotSceneGenesisPlaza" import { placeGenesisPlazaWithAggregatedAttributes } from "../../__data__/placeGenesisPlazaWithAggregatedAttributes" import { sceneStatsGenesisPlaza } from "../../__data__/sceneStatsGenesisPlaza" -import { placesWithCoordinatesAggregates } from "./utils" +import { worldsLiveData } from "../../__data__/worldsLiveData" +import { + allPlacesWithAggregates, + placesWithCoordinatesAggregates, +} from "./utils" describe("get of AggregatePlaceAttributes", () => { test("should return a place of type AggregateCoordinatePlaceAttributes", () => { @@ -67,3 +72,80 @@ describe("get of AggregatePlaceAttributes", () => { }) }) }) + +describe("get of AllPlacesWithAggregates", () => { + test("should return a place of type AggregateCoordinatePlaceAttributes", () => { + const places = allPlacesWithAggregates( + allPlacesWithAggregatedAttributes, + [hotSceneGenesisPlaza], + sceneStatsGenesisPlaza, + worldsLiveData + ) + expect(places).toEqual([ + { + ...allPlacesWithAggregatedAttributes[0], + user_count: hotSceneGenesisPlaza.usersTotalCount, + user_visits: + sceneStatsGenesisPlaza[ + allPlacesWithAggregatedAttributes[0].base_position + ].last_30d.users, + }, + { + ...allPlacesWithAggregatedAttributes[1], + user_count: worldsLiveData.perWorld[0].users, + // TODO: Get user visits from world stats + user_visits: 0, + }, + ]) + }) + + test("should return a place of type AggregatePlaceAttributes with user_visits when not match the base position", () => { + const place = { + ...allPlacesWithAggregatedAttributes[0], + base_position: "-1,-1", + } + const places = allPlacesWithAggregates( + [place], + [hotSceneGenesisPlaza], + sceneStatsGenesisPlaza, + worldsLiveData + ) + expect(places).toEqual([ + { + ...place, + user_count: hotSceneGenesisPlaza.usersTotalCount, + user_visits: + sceneStatsGenesisPlaza[ + allPlacesWithAggregatedAttributes[0].base_position + ].last_30d.users, + }, + ]) + }) + + test("should return a place of type AggregatePlaceAttributes with realm details", () => { + const places = allPlacesWithAggregates( + allPlacesWithAggregatedAttributes, + [hotSceneGenesisPlaza], + sceneStatsGenesisPlaza, + worldsLiveData, + { withRealmsDetail: true } + ) + expect(places).toEqual([ + { + ...allPlacesWithAggregatedAttributes[0], + user_count: hotSceneGenesisPlaza.usersTotalCount, + user_visits: + sceneStatsGenesisPlaza[ + allPlacesWithAggregatedAttributes[0].base_position + ].last_30d.users, + realms_detail: hotSceneGenesisPlaza.realms, + }, + { + ...allPlacesWithAggregatedAttributes[1], + user_count: worldsLiveData.perWorld[0].users, + // TODO: Get user visits from world stats + user_visits: 0, + }, + ]) + }) +}) diff --git a/src/entities/Map/utils.ts b/src/entities/Map/utils.ts index 432187d9..6277df53 100644 --- a/src/entities/Map/utils.ts +++ b/src/entities/Map/utils.ts @@ -1,5 +1,6 @@ import { SceneStats, SceneStatsMap } from "../../api/DataTeam" -import { HotScene } from "../Place/types" +import { AggregatePlaceAttributes, HotScene } from "../Place/types" +import { WorldLiveDataProps } from "../World/types" import { AggregateCoordinatePlaceAttributes } from "./types" export function placesWithCoordinatesAggregates( @@ -43,3 +44,55 @@ export function placesWithCoordinatesAggregates( return placesWithAggregates } + +export function allPlacesWithAggregates( + places: AggregatePlaceAttributes[], + hotScenes: HotScene[], + placesSceneStats: SceneStatsMap, + worldsLiveData: WorldLiveDataProps, + // worldStats: WorldStats, + options?: { + withRealmsDetail: boolean + } +) { + return places.map((place) => { + const placesStats: SceneStats | undefined = + placesSceneStats[place.base_position] || + (place.positions || []).reduce( + (acc, position) => acc || placesSceneStats[position], + undefined + ) + let user_count = 0 + let user_visits = 0 + + const hotScenePlaces = hotScenes.find((scene) => + scene.parcels + .map((parsel) => parsel.join(",")) + .includes(place.base_position) + ) + + if (place.world) { + user_count = + (worldsLiveData?.perWorld && + worldsLiveData.perWorld.find( + (world) => world.worldName === place.world_name + )?.users) || + 0 + // TODO: Get Worlds user visits + // user_visits = worldStats?.last_30d?.users || 0 + } else { + user_count = hotScenePlaces?.usersTotalCount || 0 + user_visits = placesStats?.last_30d?.users || 0 + } + + if (options?.withRealmsDetail && !place.world) { + place.realms_detail = hotScenePlaces?.realms || [] + } + + return { + ...place, + user_visits: user_visits, + user_count: user_count, + } + }) +} diff --git a/src/entities/Place/model.ts b/src/entities/Place/model.ts index 134b21ed..1935be87 100644 --- a/src/entities/Place/model.ts +++ b/src/entities/Place/model.ts @@ -21,6 +21,7 @@ import isEthereumAddress from "validator/lib/isEthereumAddress" import { type AggregateCoordinatePlaceAttributes, DEFAULT_MAX_LIMIT as DEFAULT_MAP_MAX_LIMIT, + FindAllPlacesWithAggregatesOptions, } from "../Map/types" import PlaceCategories from "../PlaceCategories/model" import PlacePositionModel from "../PlacePosition/model" @@ -818,4 +819,193 @@ export default class PlaceModel extends Model { return Number(results[0].total) } + + static async findAllPlacesWithAggregates( + options: FindAllPlacesWithAggregatesOptions + ): Promise { + const searchIsEmpty = options.search && options.search.length < 3 + if (searchIsEmpty) { + return [] + } + + // The columns most_active, user_visits doesn't exists in the PlaceAttributes + const orderBy = + oneOf(options.order_by, [ + PlaceListOrderBy.LIKE_SCORE_BEST, + PlaceListOrderBy.UPDATED_AT, + PlaceListOrderBy.CREATED_AT, + ]) ?? PlaceListOrderBy.LIKE_SCORE_BEST + + const orderDirection = oneOf(options.order, ["asc", "desc"]) ?? "desc" + const order = SQL.raw( + `p.${orderBy} ${orderDirection.toUpperCase()} NULLS LAST, p.deployed_at DESC` + ) + + let placesOrWorldsCondition = SQL`` + if (options.positions.length > 0 && options.names.length > 0) { + placesOrWorldsCondition = SQL`AND ( + p.base_position IN ( + SELECT DISTINCT(base_position) + FROM ${table(PlacePositionModel)} + WHERE position IN ${values(options.positions)} + ) + OR + p.world_name IN ${values(options.names)} + )` + } else if (options.positions.length > 0) { + placesOrWorldsCondition = SQL`AND p.base_position IN ( + SELECT DISTINCT(base_position) + FROM ${table(PlacePositionModel)} + WHERE position IN ${values(options.positions)} + )` + } else if (options.names.length > 0) { + placesOrWorldsCondition = SQL`AND p.world_name IN ${values( + options.names + )}` + } + + const sql = SQL` + SELECT p.* + ${conditional( + !!options.user, + SQL`, uf."user" is not null as user_favorite, coalesce(ul."like",false) as "user_like", not coalesce(ul."like",true) as "user_dislike"` + )} + ${conditional( + !options.user, + SQL`, false as user_favorite, false as "user_like", false as "user_dislike"` + )} + FROM ${table(this)} p + ${conditional( + !!options.user && !options.only_favorites, + SQL`LEFT JOIN ${table( + UserFavoriteModel + )} uf on p.id = uf.place_id AND uf."user" = ${options.user}` + )} + ${conditional( + !!options.user && options.only_favorites, + SQL`RIGHT JOIN ${table( + UserFavoriteModel + )} uf on p.id = uf.place_id AND uf."user" = ${options.user}` + )} + ${conditional( + !!options.user, + SQL`LEFT JOIN ${table( + UserLikesModel + )} ul on p.id = ul.place_id AND ul."user" = ${options.user}` + )} + ${conditional( + !!options.categories.length, + SQL`INNER JOIN ${table( + PlaceCategories + )} pc ON p.id = pc.place_id AND pc.category_id IN ${values( + options.categories + )}` + )} + ${conditional( + !!options.search, + SQL`, ts_rank_cd(p.textsearch, to_tsquery(${tsquery( + options.search || "" + )})) as rank` + )} + WHERE + p.disabled is false + ${conditional(options.only_highlighted, SQL`AND highlighted = TRUE`)} + ${conditional(!!options.search, SQL`AND rank > 0`)} + ${conditional(!!placesOrWorldsCondition, placesOrWorldsCondition)} + ORDER BY + ${conditional(!!options.search, SQL`rank DESC, `)} + ${order} + ${limit(options.limit, { max: DEFAULT_MAP_MAX_LIMIT })} + ${offset(options.offset)} + ` + + const queryResult = await this.namedQuery< + AggregatePlaceAttributes & { category_id?: string } + >("find_with_agregates", sql) + + return queryResult + } + + static async countAllPlaces( + options: Pick< + FindAllPlacesWithAggregatesOptions, + | "user" + | "only_favorites" + | "positions" + | "names" + | "only_highlighted" + | "search" + | "categories" + > + ) { + const isMissingEthereumAddress = + options.user && !isEthereumAddress(options.user) + const searchIsEmpty = options.search && options.search.length < 3 + + if (isMissingEthereumAddress || searchIsEmpty) { + return 0 + } + + let placesOrWorldsCondition = SQL`` + if (options.positions.length > 0 && options.names.length > 0) { + placesOrWorldsCondition = SQL`AND ( + p.base_position IN ( + SELECT DISTINCT(base_position) + FROM ${table(PlacePositionModel)} + WHERE position IN ${values(options.positions)} + ) + OR + p.world_name IN ${values(options.names)} + )` + } else if (options.positions.length > 0) { + placesOrWorldsCondition = SQL`AND p.base_position IN ( + SELECT DISTINCT(base_position) + FROM ${table(PlacePositionModel)} + WHERE position IN ${values(options.positions)} + )` + } else if (options.names.length > 0) { + placesOrWorldsCondition = SQL`AND p.world_name IN ${values( + options.names + )}` + } + + const query = SQL` + SELECT + count(DISTINCT p.id) as total + FROM ${table(this)} p + ${conditional( + !!options.user && options.only_favorites, + SQL`RIGHT JOIN ${table( + UserFavoriteModel + )} uf on p.id = uf.place_id AND uf.user = ${options.user}` + )} + ${conditional( + !!options.categories.length, + SQL`INNER JOIN ${table( + PlaceCategories + )} pc ON p.id = pc.place_id AND pc.category_id IN ${values( + options.categories + )}` + )} + + ${conditional( + !!options.search, + SQL`, ts_rank_cd(p.textsearch, to_tsquery(${tsquery( + options.search || "" + )})) as rank` + )} + + WHERE + p."disabled" is false + ${conditional(options.only_highlighted, SQL`AND highlighted = TRUE`)} + ${conditional(!!options.search, SQL` AND rank > 0`)} + ${conditional(!!placesOrWorldsCondition, placesOrWorldsCondition)} + ` + const results: { total: string }[] = await this.namedQuery( + "count_places", + query + ) + + return Number(results[0].total) + } } diff --git a/src/entities/World/tasks/worldsLiveData.ts b/src/entities/World/tasks/worldsLiveData.ts new file mode 100644 index 00000000..47057c3e --- /dev/null +++ b/src/entities/World/tasks/worldsLiveData.ts @@ -0,0 +1,11 @@ +import { Task } from "decentraland-gatsby/dist/entities/Task" + +import { fetchWorldsLiveDataAndUpdateCache } from "../utils" + +export const worldsLiveDataUpdate = new Task({ + name: "worlds_live_data", + repeat: Task.Repeat.Minutely, + task: async () => { + await fetchWorldsLiveDataAndUpdateCache() + }, +}) diff --git a/src/entities/World/types.ts b/src/entities/World/types.ts index d8cc182f..3fb24f9d 100644 --- a/src/entities/World/types.ts +++ b/src/entities/World/types.ts @@ -29,3 +29,13 @@ export type WorldListOptions = { export type FindWorldWithAggregatesOptions = WorldListOptions & { user?: string } + +export type WorldLivePerWorldProps = { + users: number + worldName: string +} + +export type WorldLiveDataProps = { + perWorld: WorldLivePerWorldProps[] + totalUsers: number +} diff --git a/src/entities/World/utils.ts b/src/entities/World/utils.ts index fc07ca5f..b6b2a686 100644 --- a/src/entities/World/utils.ts +++ b/src/entities/World/utils.ts @@ -1,5 +1,11 @@ -import { WorldLivePerWorldProps } from "../../modules/worldsLiveData" +import env from "decentraland-gatsby/dist/utils/env" + import { AggregatePlaceAttributes } from "../Place/types" +import { WorldLiveDataProps, WorldLivePerWorldProps } from "./types" + +const DEFAULT_WORLD_LIVE_DATA = {} as WorldLiveDataProps + +let memory = DEFAULT_WORLD_LIVE_DATA export function worldsWithUserCount( worlds: AggregatePlaceAttributes[], @@ -20,3 +26,20 @@ export function worldsWithUserCount( return worldWithAggregates }) } + +export const fetchWorldsLiveDataAndUpdateCache = async (): Promise => { + try { + const liveFetch = await fetch( + env( + "WORLDS_LIVE_DATA", + "https://worlds-content-server.decentraland.org/live-data" + ) + ) + const liveData = await liveFetch.json() + memory = liveData.data + } catch (error) { + memory = DEFAULT_WORLD_LIVE_DATA + } +} + +export const getWorldsLiveData = () => memory diff --git a/src/server.ts b/src/server.ts index 767e52ca..2b2bb382 100644 --- a/src/server.ts +++ b/src/server.ts @@ -35,6 +35,7 @@ import socialRoutes from "./entities/Social/routes" import userFavoriteRoute from "./entities/UserFavorite/routes" import userLikesRoute from "./entities/UserLikes/routes" import worldRoute from "./entities/World/routes" +import { worldsLiveDataUpdate } from "./entities/World/tasks/worldsLiveData" const tasks = tasksManager() tasks.use( @@ -53,6 +54,7 @@ tasks.use( tasks.use(checkPoisForCategoryUpdate) tasks.use(hotScenesUpdate) +tasks.use(worldsLiveDataUpdate) const app = express() app.set("x-powered-by", false)