Skip to content

Commit cbab320

Browse files
Merge pull request #810 from decentraland/feat/change-how-settings-are-shown-for-worlds
feat: Change how settings are shown for worlds
2 parents 4a1c6e1 + f80e7e8 commit cbab320

14 files changed

+676
-68
lines changed

src/entities/CheckScenes/task/handleWorldScenesUndeployment.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import { WorldScenesUndeploymentEvent } from "@dcl/schemas/dist/platform/events/world"
22
import logger from "decentraland-gatsby/dist/entities/Development/logger"
33

4-
import PlaceModel from "../../Place/model"
54
import { notifyError } from "../../Slack/utils"
5+
import WorldModel from "../../World/model"
66

77
/**
88
* Handles WorldScenesUndeploymentEvent from the worlds content server.
9-
* Deletes the place records corresponding to the undeployed scenes,
10-
* identified by world name and each scene's base parcel.
9+
* Atomically deletes place records for undeployed scenes and refreshes
10+
* the world's deployment-derived fields from the next-latest remaining scene.
1111
*/
1212
export async function handleWorldScenesUndeployment(
1313
event: WorldScenesUndeploymentEvent
@@ -41,14 +41,14 @@ export async function handleWorldScenesUndeployment(
4141
)}`
4242
)
4343

44-
await PlaceModel.deleteByWorldIdAndPositions(
44+
await WorldModel.deleteWorldScenesAndRefresh(
4545
worldName,
4646
basePositions,
4747
event.timestamp
4848
)
4949

5050
loggerExtended.log(
51-
`Deleted place records for world: ${worldName} at positions: ${basePositions.join(
51+
`Deleted place records and refreshed world: ${worldName} at positions: ${basePositions.join(
5252
", "
5353
)}`
5454
)

src/entities/CheckScenes/task/taskRunnerSqs.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import CategoryModel from "../../Category/model"
66
import { DecentralandCategories } from "../../Category/types"
77
import PlaceModel from "../../Place/model"
88
import { PlaceAttributes } from "../../Place/types"
9+
import { getThumbnailFromContentDeployment } from "../../Place/utils"
910
import PlaceCategories from "../../PlaceCategories/model"
1011
import PlaceContentRatingModel from "../../PlaceContentRating/model"
1112
import PlacePositionModel from "../../PlacePosition/model"
@@ -83,15 +84,17 @@ export async function taskRunnerSqs(job: DeploymentToSqs) {
8384
const isOptOut =
8485
!!contentEntityScene?.metadata?.worldConfiguration?.placesConfig?.optOut
8586

86-
// Insert the world only if it doesn't already exist.
87-
// If it already exists (configured via settings or a previous deployment),
88-
// its data is left untouched.
87+
const worldImage = getThumbnailFromContentDeployment(contentEntityScene, {
88+
url: job.contentServerUrls![0],
89+
})
90+
8991
const worldId = await WorldModel.insertWorldIfNotExists({
9092
world_name: worldName,
9193
title:
9294
contentEntityScene?.metadata?.display?.title?.slice(0, 50) || undefined,
9395
description:
9496
contentEntityScene?.metadata?.display?.description || undefined,
97+
image: worldImage,
9598
content_rating:
9699
(contentEntityScene?.metadata?.policy
97100
?.contentRating as SceneContentRating) || undefined,

src/entities/Destination/model.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
} from "./types"
1313
import PlaceModel from "../Place/model"
1414
import { HotScene, PlaceListOrderBy } from "../Place/types"
15+
import { DEFAULT_WORLD_IMAGE } from "../shared/constants"
1516
import WorldModel from "../World/model"
1617

1718
/**
@@ -36,7 +37,7 @@ const PLACES_DESTINATION_SELECT = SQL`
3637
* using the lateral join (lp) for place-derived fields.
3738
*/
3839
const WORLDS_DESTINATION_SELECT = SQL`
39-
w.id, w.title, w.description, COALESCE(w.image, lp.image) as image,
40+
w.id, w.title, w.description, COALESCE(w.image, ${DEFAULT_WORLD_IMAGE}) as image,
4041
w.owner, w.world_name,
4142
w.content_rating, w.categories, w.likes, w.dislikes, w.favorites,
4243
w.like_rate, w.like_score, w.disabled, w.disabled_at,

src/entities/Place/utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from "./types"
1111
import { SceneStats, SceneStatsMap } from "../../api/DataTeam"
1212
import toCanonicalPosition from "../../utils/position/toCanonicalPosition"
13+
import { DEFAULT_WORLD_IMAGE } from "../shared/constants"
1314
import { AnyEntityAttributes } from "../shared/entityTypes"
1415

1516
const DECENTRALAND_URL =
@@ -127,8 +128,7 @@ export function getThumbnailFromContentDeployment(
127128
}
128129

129130
if (!thumbnail && deployment?.metadata?.worldConfiguration) {
130-
thumbnail =
131-
"https://peer.decentraland.org/content/contents/bafkreidj26s7aenyxfthfdibnqonzqm5ptc4iamml744gmcyuokewkr76y"
131+
thumbnail = DEFAULT_WORLD_IMAGE
132132
} else if (!thumbnail) {
133133
thumbnail = Land.getInstance().getMapImage({
134134
selected: positions,

src/entities/World/model.ts

Lines changed: 145 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
limit,
88
offset,
99
table,
10+
tsquery,
1011
values,
1112
} from "decentraland-gatsby/dist/entities/Database/utils"
1213
import { oneOf } from "decentraland-gatsby/dist/entities/Schema/utils"
@@ -19,13 +20,13 @@ import {
1920
WorldAttributes,
2021
WorldListOrderBy,
2122
} from "./types"
23+
import { DEFAULT_WORLD_IMAGE } from "../shared/constants"
2224
import {
2325
buildTextsearch,
2426
buildUpdateFavoritesQuery,
2527
buildUpdateLikesQuery,
2628
buildUserInteractionColumns,
2729
buildUserInteractionJoins,
28-
buildWorldTextSearchRank,
2930
} from "../shared/entityInteractions"
3031
import UserFavoriteModel from "../UserFavorite/model"
3132
import UserLikesModel from "../UserLikes/model"
@@ -46,7 +47,7 @@ export default class WorldModel extends Model<WorldAttributes> {
4647
static buildLatestPlaceLateralJoin(worldAlias: string): SQLStatement {
4748
const a = SQL.raw(worldAlias)
4849
return SQL`LEFT JOIN LATERAL (
49-
SELECT p.image, p.contact_name, p.contact_email,
50+
SELECT p.contact_name, p.contact_email,
5051
p.creator_address, p.sdk, p.deployed_at
5152
FROM places p
5253
WHERE p.world_id = ${a}.id AND p.disabled IS FALSE
@@ -97,7 +98,9 @@ export default class WorldModel extends Model<WorldAttributes> {
9798
)}
9899
${conditional(
99100
!!options.search,
100-
SQL`AND ${buildWorldTextSearchRank(alias, options.search || "")} > 0`
101+
SQL`AND ${a}.textsearch @@ to_tsquery(${tsquery(
102+
options.search || ""
103+
)})`
101104
)}
102105
${conditional(
103106
(options.world_names?.length ?? 0) > 0,
@@ -175,7 +178,7 @@ export default class WorldModel extends Model<WorldAttributes> {
175178
): SQLStatement {
176179
const forCount = opts?.forCount ?? false
177180
const defaultSelectColumns = SQL`w.*
178-
, COALESCE(w.image, lp.image) as image
181+
, COALESCE(w.image, ${DEFAULT_WORLD_IMAGE}) as image
179182
, lp.contact_name
180183
, '0,0' as base_position
181184
, true as world
@@ -193,7 +196,9 @@ export default class WorldModel extends Model<WorldAttributes> {
193196
)}
194197
${conditional(
195198
!forCount && !!options.search,
196-
SQL`, ${buildWorldTextSearchRank("w", options.search || "")} as rank`
199+
SQL`, ts_rank_cd(w.textsearch, to_tsquery(${tsquery(
200+
options.search || ""
201+
)})) as rank`
197202
)}
198203
FROM ${table(this)} w
199204
${this.buildLatestPlaceLateralJoin("w")}
@@ -252,7 +257,7 @@ export default class WorldModel extends Model<WorldAttributes> {
252257
): Promise<AggregateWorldAttributes | null> {
253258
const sql = SQL`
254259
SELECT w.*
255-
, COALESCE(w.image, lp.image) as image
260+
, COALESCE(w.image, ${DEFAULT_WORLD_IMAGE}) as image
256261
, lp.contact_name
257262
, '0,0' as base_position
258263
${conditional(
@@ -274,13 +279,7 @@ export default class WorldModel extends Model<WorldAttributes> {
274279
, 0 as user_visits
275280
, lp.deployed_at
276281
FROM ${table(this)} w
277-
LEFT JOIN LATERAL (
278-
SELECT p.image, p.contact_name, p.deployed_at
279-
FROM places p
280-
WHERE p.world_id = w.id AND p.disabled IS FALSE
281-
ORDER BY p.deployed_at DESC
282-
LIMIT 1
283-
) lp ON true
282+
${this.buildLatestPlaceLateralJoin("w")}
284283
${conditional(
285284
!!options.user,
286285
SQL`LEFT JOIN ${table(
@@ -439,6 +438,8 @@ export default class WorldModel extends Model<WorldAttributes> {
439438
highlighted: world.highlighted ?? false,
440439
highlighted_image: world.highlighted_image ?? null,
441440
ranking: world.ranking ?? 0,
441+
settings_configured: world.settings_configured ?? false,
442+
textsearch: undefined,
442443
likes: world.likes ?? 0,
443444
dislikes: world.dislikes ?? 0,
444445
favorites: world.favorites ?? 0,
@@ -452,22 +453,24 @@ export default class WorldModel extends Model<WorldAttributes> {
452453
}
453454

454455
/**
455-
* Insert a world only if it doesn't already exist.
456-
* Uses INSERT ... ON CONFLICT (id) DO NOTHING for atomicity.
457-
* Returns the world ID (lowercased world_name) regardless of whether
458-
* the insert was performed.
456+
* Insert a world or update its deployment-derived fields if settings haven't been
457+
* explicitly configured. Uses INSERT ... ON CONFLICT (id) DO UPDATE ... WHERE
458+
* settings_configured = false so that user-configured worlds are never overwritten
459+
* by deployments.
459460
*/
460461
static async insertWorldIfNotExists(
461462
world: Partial<WorldAttributes> & { world_name: string }
462463
): Promise<string> {
463464
const worldData = this.buildWorldData(world)
465+
const textsearch = this.textsearch(worldData)
464466

465467
const sql = SQL`
466468
INSERT INTO ${table(this)} (
467469
"id", "world_name", "title", "description", "image",
468470
"content_rating", "categories", "owner", "show_in_places",
469471
"single_player", "skybox_time", "is_private",
470472
"highlighted", "highlighted_image", "ranking",
473+
"settings_configured", "textsearch",
471474
"likes", "dislikes", "favorites",
472475
"like_rate", "like_score", "disabled", "disabled_at",
473476
"created_at", "updated_at"
@@ -487,6 +490,8 @@ export default class WorldModel extends Model<WorldAttributes> {
487490
${worldData.highlighted},
488491
${worldData.highlighted_image},
489492
${worldData.ranking},
493+
${worldData.settings_configured},
494+
${textsearch},
490495
${worldData.likes},
491496
${worldData.dislikes},
492497
${worldData.favorites},
@@ -497,7 +502,15 @@ export default class WorldModel extends Model<WorldAttributes> {
497502
${worldData.created_at},
498503
${worldData.updated_at}
499504
)
500-
ON CONFLICT (id) DO NOTHING
505+
ON CONFLICT (id) DO UPDATE SET
506+
title = EXCLUDED.title,
507+
description = EXCLUDED.description,
508+
image = EXCLUDED.image,
509+
content_rating = EXCLUDED.content_rating,
510+
categories = EXCLUDED.categories,
511+
textsearch = EXCLUDED.textsearch,
512+
updated_at = EXCLUDED.updated_at
513+
WHERE worlds.settings_configured = false
501514
`
502515

503516
await this.namedQuery("insert_world_if_not_exists", sql)
@@ -507,13 +520,15 @@ export default class WorldModel extends Model<WorldAttributes> {
507520
static async upsertWorld(
508521
world: Partial<WorldAttributes> & { world_name: string }
509522
): Promise<WorldAttributes> {
510-
const worldData = this.buildWorldData(world)
523+
const worldData = this.buildWorldData({
524+
...world,
525+
settings_configured: true,
526+
})
527+
const textsearch = this.textsearch(worldData)
511528

512-
// Fields that can be updated on conflict (excludes id, world_name, likes, etc.)
513529
const updatableFields: (keyof WorldAttributes)[] = [
514530
"title",
515531
"description",
516-
"image",
517532
"content_rating",
518533
"categories",
519534
"owner",
@@ -523,22 +538,120 @@ export default class WorldModel extends Model<WorldAttributes> {
523538
"is_private",
524539
]
525540

526-
// Build changes object with only explicitly provided fields
527-
// This ensures we don't overwrite existing values with defaults on conflict
528-
const changes: Partial<WorldAttributes> = {
529-
updated_at: worldData.updated_at,
530-
}
541+
const setClauses: SQLStatement[] = [
542+
SQL`"settings_configured" = true`,
543+
SQL`"textsearch" = ${textsearch}`,
544+
SQL`"image" = EXCLUDED."image"`,
545+
SQL`"updated_at" = EXCLUDED."updated_at"`,
546+
]
547+
531548
for (const field of updatableFields) {
532549
if (world[field] !== undefined) {
533-
;(changes as Record<string, unknown>)[field] = world[field]
550+
setClauses.push(SQL`"${SQL.raw(field)}" = EXCLUDED."${SQL.raw(field)}"`)
534551
}
535552
}
536553

537-
// Upsert on id (lowercased world_name) as the conflict target
538-
return this.upsert(worldData, {
539-
target: ["id"],
540-
changes,
541-
})
554+
const sql = SQL`
555+
INSERT INTO ${table(this)} (
556+
"id", "world_name", "title", "description", "image",
557+
"content_rating", "categories", "owner", "show_in_places",
558+
"single_player", "skybox_time", "is_private",
559+
"highlighted", "highlighted_image", "ranking",
560+
"settings_configured", "textsearch",
561+
"likes", "dislikes", "favorites",
562+
"like_rate", "like_score", "disabled", "disabled_at",
563+
"created_at", "updated_at"
564+
) VALUES (
565+
${worldData.id},
566+
${worldData.world_name},
567+
${worldData.title},
568+
${worldData.description},
569+
${worldData.image},
570+
${worldData.content_rating},
571+
${worldData.categories},
572+
${worldData.owner},
573+
${worldData.show_in_places},
574+
${worldData.single_player},
575+
${worldData.skybox_time},
576+
${worldData.is_private},
577+
${worldData.highlighted},
578+
${worldData.highlighted_image},
579+
${worldData.ranking},
580+
${worldData.settings_configured},
581+
${textsearch},
582+
${worldData.likes},
583+
${worldData.dislikes},
584+
${worldData.favorites},
585+
${worldData.like_rate},
586+
${worldData.like_score},
587+
${worldData.disabled},
588+
${worldData.disabled_at},
589+
${worldData.created_at},
590+
${worldData.updated_at}
591+
)
592+
ON CONFLICT (id) DO UPDATE SET
593+
${join(setClauses, SQL`, `)}
594+
RETURNING *
595+
`
596+
597+
const results = await this.namedQuery<WorldAttributes>("upsert_world", sql)
598+
return results[0]
599+
}
600+
601+
/**
602+
* Atomically delete place records for undeployed scenes and refresh the world's
603+
* deployment-derived fields from the next-latest remaining scene. All operations
604+
* happen in a single SQL statement (CTE-based) to prevent inconsistent state.
605+
*
606+
* If settings_configured = true, the world update is skipped (user settings preserved).
607+
* If no remaining places exist, the UPDATE produces no rows (world stays as-is but
608+
* won't appear in queries since they check EXISTS(places)).
609+
*/
610+
static async deleteWorldScenesAndRefresh(
611+
worldId: string,
612+
basePositions: string[],
613+
eventTimestamp: number
614+
): Promise<void> {
615+
const normalizedWorldId = worldId.toLowerCase()
616+
const eventDate = new Date(eventTimestamp)
617+
618+
const sql = SQL`
619+
WITH deleted AS (
620+
DELETE FROM places
621+
WHERE world_id = ${normalizedWorldId}
622+
AND base_position = ANY(${basePositions})
623+
AND deployed_at < ${eventDate}
624+
RETURNING world_id
625+
),
626+
latest_remaining AS (
627+
SELECT p.title, p.description, p.image, p.content_rating, p.categories
628+
FROM places p
629+
WHERE p.world_id = ${normalizedWorldId}
630+
AND p.disabled IS FALSE
631+
AND p.base_position != ALL(${basePositions})
632+
ORDER BY p.deployed_at DESC
633+
LIMIT 1
634+
)
635+
UPDATE worlds SET
636+
title = lr.title,
637+
description = lr.description,
638+
image = lr.image,
639+
content_rating = COALESCE(lr.content_rating, worlds.content_rating),
640+
categories = COALESCE(lr.categories, worlds.categories),
641+
textsearch = (
642+
setweight(to_tsvector(coalesce(lr.title, '')), 'A') ||
643+
setweight(to_tsvector(coalesce(worlds.world_name, '')), 'A') ||
644+
setweight(to_tsvector(coalesce(lr.description, '')), 'B') ||
645+
setweight(to_tsvector(coalesce(worlds.owner, '')), 'C')
646+
),
647+
updated_at = now()
648+
FROM latest_remaining lr
649+
WHERE worlds.id = ${normalizedWorldId}
650+
AND worlds.settings_configured = false
651+
AND EXISTS (SELECT 1 FROM deleted)
652+
`
653+
654+
await this.namedQuery("delete_world_scenes_and_refresh", sql)
542655
}
543656

544657
static async disableWorld(worldName: string): Promise<void> {

0 commit comments

Comments
 (0)