diff --git a/adminSiteClient/admin.scss b/adminSiteClient/admin.scss index d995aca4ea..93ff7b3518 100644 --- a/adminSiteClient/admin.scss +++ b/adminSiteClient/admin.scss @@ -740,7 +740,8 @@ $nav-height: 45px; } .errorMessage { - z-index: 2000; + // 1 above .ant-modal-mask + z-index: 2001; .modal-dialog { max-width: 80%; } diff --git a/adminSiteServer/apiRoutes/gdocs.ts b/adminSiteServer/apiRoutes/gdocs.ts index 1a84b0e94d..a5381dd872 100644 --- a/adminSiteServer/apiRoutes/gdocs.ts +++ b/adminSiteServer/apiRoutes/gdocs.ts @@ -33,7 +33,7 @@ import { updateGdocContentOnly, createOrLoadGdocById, gdocFromJSON, - addImagesToContentGraph, + setImagesInContentGraph, setLinksForGdoc, GdocLinkUpdateMode, upsertGdoc, @@ -145,6 +145,20 @@ async function indexAndBakeGdocIfNeccesary( .exhaustive() } +async function validateSlugCollisionsIfPublishing( + trx: db.KnexReadonlyTransaction, + gdoc: GdocPost | GdocDataInsight | GdocHomepage | GdocAbout | GdocAuthor +) { + if (!gdoc.published) return + + const hasSlugCollision = await db.checkIfSlugCollides(trx, gdoc) + if (hasSlugCollision) { + throw new JsonError( + `You are attempting to publish a Google Doc with a slug that already exists: "${gdoc.slug}"` + ) + } +} + /** * Only supports creating a new empty Gdoc or updating an existing one. Does not * support creating a new Gdoc from an existing one. Relevant updates will @@ -167,7 +181,9 @@ export async function createOrUpdateGdoc( const nextGdoc = gdocFromJSON(req.body) await nextGdoc.loadState(trx) - await addImagesToContentGraph(trx, nextGdoc) + await validateSlugCollisionsIfPublishing(trx, nextGdoc) + + await setImagesInContentGraph(trx, nextGdoc) await setLinksForGdoc( trx, diff --git a/db/db.ts b/db/db.ts index d64f6d29ad..211fe168cd 100644 --- a/db/db.ts +++ b/db/db.ts @@ -33,9 +33,12 @@ import { DbEnrichedImageWithUserId, MinimalTag, BreadcrumbItem, + PostsGdocsTableName, + OwidGdocBaseInterface, } from "@ourworldindata/types" import { groupBy } from "lodash" import { gdocFromJSON } from "./model/Gdoc/GdocFactory.js" +import { getCanonicalUrl } from "@ourworldindata/components" // Return the first match from a mysql query export const closeTypeOrmAndKnexConnections = async (): Promise => { @@ -288,6 +291,29 @@ export const getPublishedDataInsights = ( ) as Promise } +export async function checkIfSlugCollides( + knex: KnexReadonlyTransaction, + gdoc: OwidGdocBaseInterface +): Promise { + const existingGdoc = await knex(PostsGdocsTableName) + .where({ + slug: gdoc.slug, + published: true, + }) + .whereNot({ + id: gdoc.id, + }) + .first() + .then((row) => (row ? parsePostsGdocsRow(row) : undefined)) + + if (!existingGdoc) return false + + const existingCanonicalUrl = getCanonicalUrl("", existingGdoc) + const incomingCanonicalUrl = getCanonicalUrl("", gdoc) + + return existingCanonicalUrl === incomingCanonicalUrl +} + export const getPublishedDataInsightCount = ( knex: KnexReadonlyTransaction ): Promise => { diff --git a/db/model/Gdoc/GdocFactory.ts b/db/model/Gdoc/GdocFactory.ts index 5323228022..baa58daa4e 100644 --- a/db/model/Gdoc/GdocFactory.ts +++ b/db/model/Gdoc/GdocFactory.ts @@ -661,7 +661,7 @@ export async function getAllGdocIndexItemsOrderedByUpdatedAt( ) } -export async function addImagesToContentGraph( +export async function setImagesInContentGraph( trx: KnexReadWriteTransaction, gdoc: GdocPost | GdocDataInsight | GdocHomepage | GdocAbout | GdocAuthor ): Promise {