Skip to content

[WHIT-2926] Allow offsite links to have many parents#11001

Merged
eYinka merged 1 commit into
mainfrom
many-offsite-link-parents
Jan 26, 2026
Merged

[WHIT-2926] Allow offsite links to have many parents#11001
eYinka merged 1 commit into
mainfrom
many-offsite-link-parents

Conversation

@eYinka
Copy link
Copy Markdown
Contributor

@eYinka eYinka commented Jan 14, 2026

What

In order to enable standard editions to be associated with offsite links, we are introducing a join table. This will allow an offsite link to be associated with multiple editions. This should not introduce any behavioural changes to organisations, topical events or world location news.

We opted for releasing the data migration alongside the code, as to mitigate against new data entering the system between the time the pods are created (code becomes available) and the migration runs. This helps us to run both together and pretty quick - to avoid having data in bad state.

Why

We are making topical events config-driven and thus editionable (using the StandardEdition model).
The implementation - partly outlined in the spike at #10984 - suggests that we will be using the FeatureList model with Feature.

The problem we're trying to solve arises when creating a new draft. Scenario with tables outlines:

editions
ID 1 configurable_document_type topical_event
feature_lists
ID 10 featurable_id 1 featurable_type "StandardEdition"

We create two links:

offsite_links
ID 1 parent_type StandardEdition parent_id 1
ID 2 parent_type StandardEdition parent_id 1

We feature the first link only:

features
ID 1 feature_list_id 10 offsite_link_id 1

We make a new edition. The tables become:

editions
ID 1 configurable_document_type topical_event
ID 2 configurable_document_type topical_event
feature_lists
ID 10 featurable_id 1 featurable_type "StandardEdition"
Copied over: ID 11 featurable_id 1 featurable_type "StandardEdition"
features
ID 1 feature_list_id 10 offsite_link_id 1
Copied over: ID 2 feature_list_id 11 offsite_link_id 1

Our features are now pointing to an offsite link that doesn't exist. We also need to link them up to the new feature list we've created (we can probably do that by feature_list.features.for_each and generate new features).

We can try to copy over the offsite links too:

offsite_links
ID 1 parent_type StandardEdition parent_id 1
ID 2 parent_type StandardEdition parent_id 1
Copied: ID 3 parent_type StandardEdition parent_id 1
Copied: ID 4 parent_type StandardEdition parent_id 1

⛓️‍💥 Problem:

  • It's not immediately straightforward how we may disambiguate which link to replace our featured offsite_link_id 1 in the features table with.
  • We could also try to regenerate the offsite links as we copy over the features. Feature -> copy -> generate new link -> link feature up to new link. But that means we lose all our unfeatured links.

✨ Solution

We persist the same offsite link across editions. We introduce a join table between editions (featurable) and their offsite links, as to allow an offsite link to have multiple editions.

We start off in the same way, with an edition with two offsite links, one of them featured. The tables are:

editions
ID 1 configurable_document_type topical_event
feature_lists
ID 10 featurable_id 1 featurable_type "StandardEdition"
offsite_links
ID 1
ID 2
offsite_link_parents
ID 1 offsite_link_id 1 parent_type StandardEdition parent_id 1
ID 2 offsite_link_id 2 parent_type StandardEdition parent_id 1

We feature the first link only:

features
ID 1 feature_list_id 10 offsite_link_id 1

When we make a new draft, the tables become:

editions
ID 1 configurable_document_type topical_event
ID 2 configurable_document_type topical_event
feature_lists
ID 10 featurable_id 1 featurable_type "StandardEdition"
Copied over: ID 11 featurable_id 1 featurable_type "StandardEdition"
offsite_links - no change
ID 1
ID 2

We will be adding the new edition as a new parent for all our existing links, featured or not. The join table looks like so:

offsite_link_parents
ID 1 offsite_link_id 1 parent_type StandardEdition parent_id 1
ID 1 offsite_link_id 2 parent_type StandardEdition parent_id 1
New, linking to new edition: ID 3 offsite_link_id 1 parent_type StandardEdition parent_id 2
New, linking to new edition: ID 4 offsite_link_id 2 parent_type StandardEdition parent_id 2

The feature can correctly continue pointing to the offsite link ID:

features
ID 1 feature_list_id 10 offsite_link_id 1
Copied over under new list: ID 2 feature_list_id 11 offsite_link_id 1

What this allows us to do:

  • know which offsite links belong to an edition
  • deleting a link can work by only removing its specific parent association
    • this means we can preserve state in scenarios such as discarding a draft, where affecting the original offsite_links table would affect future draft generation
    • we don't affect the live edition in the event of a republish
    • any callbacks that might affect the live (though they should not run for editionable) don't inadvertently remove an offsite link

Test plan

  • Run migrations up; confirm offsite_link_parents exists and is populated from legacy columns.
    Spot-check that offsite links with legacy parents now have rows in the join table.

    • 4,172 rows in the offsite_links -> 4,172 rows in the offsite_link_parents table
    • identified 29 offsite_links without a parent; the table was populated correctly but the organisation, topical_event or world_location_news had been deleted
  • Create offsite link under each parent type:

    • Organisation → create offsite link; ensure it saves.
    • World Location News → create offsite link; ensure it saves.
    • Topical Event → create offsite link; ensure it saves.
  • For each parent type above:

    • Ensure "Edit" link goes to the correct nested edit page.
    • Feature an offsite link, verify it works and shows up on the "Currently featured" tab
    • Unfeature the same link, verify it works and is returned to the "Non-GOV.UK government links" tab
    • Check that you can delete a featured link
    • Check that you can update an existing offsite link
  • Ensure you're unable to create an offsite link without a parent type. NOT SURE IF THIS CAN BE TESTED

  • Post migration, worth also checking that there are no orphaned Offsite Links created between this PR and [WHIT-2926] Drop offsite links polymorphic columns #11029 that drops parent_id and parent_type columns from offsite_links table. In Rails console, runOffsiteLink.where(parent_id: nil). The expectation is to have 0 results, unless the data was inserted prior to now.

Jira

Follow-up PRs:
#11028
#11029

@eYinka eYinka changed the title [WHIT-2926] WIP: Allow offsite links to have many parents [WHIT-2926] Allow offsite links to have many parents Jan 14, 2026
@ryanb-gds ryanb-gds force-pushed the many-offsite-link-parents branch from e3eaeb5 to 3202fcd Compare January 14, 2026 16:03
@eYinka eYinka force-pushed the many-offsite-link-parents branch 3 times, most recently from 5bac9ea to 06de1d7 Compare January 21, 2026 10:45
@lauraghiorghisor-tw lauraghiorghisor-tw force-pushed the many-offsite-link-parents branch 5 times, most recently from fb20c65 to 5f6062f Compare January 21, 2026 16:59
@lauraghiorghisor-tw lauraghiorghisor-tw marked this pull request as ready for review January 21, 2026 17:04
add_index :offsite_link_parents,
%i[parent_type parent_id offsite_link_id],
unique: true
end
Copy link
Copy Markdown
Contributor

@lauraghiorghisor-tw lauraghiorghisor-tw Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Things we considered here. When we delete an offsite link, the row in the join table is not automatically deleted, and we could delete it as there is no need for it.

On the OffsiteLink model, we tried:
has_many :offsite_link_parents, dependent: :destroy
-> but that removed the relationship to the topical_event, breaking the call to republish the topical event

def republish_parent_to_publishing_api

We could try

add_foreign_key :offsite_link_parents,
                :offsite_links,
                on_delete: :nullify

-> but that means we need to remove the not null constraint on the offsite_link_id. And it might still break as per the above.

Given this, we considered it generally fine to have stale data in the join table, but happy to hear a different take.

# We only expect one type of parent per offsite link (either organisations, or topical events, or world location news).
# Once topical events become editionable, the parents will return all editions associated with the offsite link.
def parents
organisations + topical_events + world_location_news
Copy link
Copy Markdown
Contributor

@lauraghiorghisor-tw lauraghiorghisor-tw Jan 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All in all, this carries some level of risk. It is practically impossible to create a multi parent link off different types.

We considered rewriting it such that we introduce the join table and still keep a 1:1 for the non-editionable models. But that didn't look very straightforward.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point! In reality, we can only have one parent type. So this will mostly likely only return an array of 1 element at any given time. Definitely will need a revisit after this scope.

Copy link
Copy Markdown
Contributor

@ryanb-gds ryanb-gds left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is good to go.

Comment thread app/models/offsite_link.rb Outdated
end

def republish_parent_to_publishing_api
def republish_parents_to_publishing_api
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure this method name should have changed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good spot!

In order to enable standard editions to be associated with offsite links, we are introducing a join table. This will allow an offsite link to be associated with multiple editions. This should not introduce any behavioural changes to organisations, topical events or world location news.

We opted for releasing the data migration alongside the code so it runs immediately as pods come up, eliminating the gap where new offsite links could be created in the old shape. Running both together keeps the window for inconsistent data essentially zero.
@eYinka eYinka force-pushed the many-offsite-link-parents branch from 2463604 to 9d031e9 Compare January 23, 2026 10:29
@eYinka eYinka merged commit 8b597dc into main Jan 26, 2026
25 checks passed
@eYinka eYinka deleted the many-offsite-link-parents branch January 26, 2026 09:31
eYinka added a commit that referenced this pull request Feb 20, 2026
…oves the association, not the offsite link itself

In the existing implementation, deleting an offsite link from a standard edition would delete the offsite link record itself,
which would affect other editions that are associated with the same offsite link.
I've made sure only the association between the edition and the offsite link is removed, ß
while retaining the offsite link record or any other editions that may be using it.
This follows the established design from #11001
eYinka added a commit that referenced this pull request Feb 20, 2026
…oves the association, not the offsite link itself

In the existing implementation, deleting an offsite link from a standard edition would delete the offsite link record itself,
which would affect other editions that are associated with the same offsite link.
I've made sure only the association between the edition and the offsite link is removed, ß
while retaining the offsite link record or any other editions that may be using it.
This follows the established design from #11001
eYinka added a commit that referenced this pull request Feb 20, 2026
…oves the association, not the offsite link itself

In the existing implementation, deleting an offsite link from a standard edition would delete the offsite link record itself,
which would affect other editions that are associated with the same offsite link.
I've made sure only the association between the edition and the offsite link is removed, ß
while retaining the offsite link record or any other editions that may be using it.
This follows the established design from #11001
eYinka added a commit that referenced this pull request Feb 20, 2026
…oves the association, not the offsite link itself

In the existing implementation, deleting an offsite link from a standard edition would delete the offsite link record itself,
which would affect other editions that are associated with the same offsite link.
I've made sure only the association between the edition and the offsite link is removed, ß
while retaining the offsite link record or any other editions that may be using it.
This follows the established design from #11001
eYinka added a commit that referenced this pull request Feb 20, 2026
…oves the association, not the offsite link itself

In the existing implementation, deleting an offsite link from a standard edition would delete the offsite link record itself,
which would affect other editions that are associated with the same offsite link.
I've made sure only the association between the edition and the offsite link is removed, ß
while retaining the offsite link record or any other editions that may be using it.
This follows the established design from #11001
eYinka added a commit that referenced this pull request Feb 20, 2026
…oves the association, not the offsite link itself

In the existing implementation, deleting an offsite link from a standard edition would delete the offsite link record itself,
which would affect other editions that are associated with the same offsite link.
I've made sure only the association between the edition and the offsite link is removed,
while retaining the offsite link record or any other editions that may be using it.
This follows the established design from #11001
eYinka added a commit that referenced this pull request Feb 20, 2026
…oves the association, not the offsite link itself

In the existing implementation, deleting an offsite link from a standard edition would delete the offsite link record itself,
which would affect other editions that are associated with the same offsite link.
I've made sure only the association between the edition and the offsite link is removed,
while retaining the offsite link record or any other editions that may be using it.
This follows the established design from #11001
eYinka added a commit that referenced this pull request Feb 20, 2026
…oves the association, not the offsite link itself

In the existing implementation, deleting an offsite link from a standard edition would delete the offsite link record itself,
which would affect other editions that are associated with the same offsite link.
I've made sure only the association between the edition and the offsite link is removed,
while retaining the offsite link record or any other editions that may be using it.
This follows the established design from #11001
eYinka added a commit that referenced this pull request Feb 23, 2026
…oves the association, not the offsite link itself

In the existing implementation, deleting an offsite link from a standard edition would delete the offsite link record itself,
which would affect other editions that are associated with the same offsite link.
I've made sure only the association between the edition and the offsite link is removed,
while retaining the offsite link record or any other editions that may be using it.
This follows the established design from #11001
lauraghiorghisor-tw pushed a commit that referenced this pull request Feb 24, 2026
…oves the association, not the offsite link itself

In the existing implementation, deleting an offsite link from a standard edition would delete the offsite link record itself,
which would affect other editions that are associated with the same offsite link.
I've made sure only the association between the edition and the offsite link is removed,
while retaining the offsite link record or any other editions that may be using it.
This follows the established design from #11001
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants