Packages backend - #2078
Packages backend#2078fredex42 wants to merge 34 commits into
Conversation
…ntend as Editions
There was a problem hiding this comment.
🟡 Changes recommended
Critical compilation, migration, routing, and data-handling issues remain unresolved.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
Adds a PostgreSQL-backed /packages backend for standalone recipe and story packages, including APIs, persistence, client types, documentation, and tests.
Changes:
- Adds package models, database queries, schema evolution, controllers, and integration tests.
- Renames
EditionsDBtoFaciaDB. - Adds TypeScript API types, documentation, and package routes.
File summaries
| File | Reviewed changes and final findings |
|---|---|
test/tools/FaciaApiTest.scala |
Reformats test helper calls. |
test/services/editions/publishing/PublishingTest.scala |
Updates database mock references. |
test/services/editions/db/PackageDBTest.scala |
Adds package persistence tests. |
test/services/editions/db/EditionsDBTest.scala |
Updates renamed database references. |
test/services/editions/db/EditionsDBEvolutionsTest.scala |
Updates evolution fixture references. |
test/fixtures/FaciaDBService.scala |
Adds the renamed database fixture. |
test/fixtures/FaciaDBEvolutions.scala |
Renames the evolution fixture. |
test/controllers/PackageControllerHttpIntegrationTest.scala |
Adds package endpoint integration tests. |
fronts-client/src/types/FaciaApi.ts |
Adds package API types. Moderate, 3 votes: new types are omitted from the export block. |
docs/Packages API.md |
Documents package APIs. Nit, 2 votes: use its instead of it's at lines 9, 24, and 40. |
conf/routes |
Adds package routes. Moderate, 3 votes: the content PATCH action has no route. |
conf/logback.xml |
Changes logging configuration. Moderate, 1 vote: structured logging is lost. Moderate, 2 votes: rolling file logging is disabled. |
conf/evolutions/default/14.sql |
Adds package tables. Critical, 1 vote: evolution markers use an unsupported format. |
app/services/editions/publishing/Publishing.scala |
Uses FaciaDB. |
app/services/editions/publishing/events/PublishEventsListener.scala |
Uses FaciaDB. |
app/services/editions/db/PackageQueries.scala |
Implements package queries. Critical, 2 votes: malformed interpolated SQL will not compile. Moderate, 3 votes: title ordering uses the wrong column. Moderate, 3 votes: overwrites do not update card_type. Moderate, 3 votes: patch insertion does not reindex existing cards. Moderate, 2 votes: the non-strict timestamp filter excludes the boundary. Nit, 2 votes: identifer is misspelled. Nit, 1 vote: documentation says 410 while the handler returns 409. |
app/services/editions/db/IssueQueries.scala |
Updates renamed database references. |
app/services/editions/db/FaciaDB.scala |
Renames and extends the database facade. |
app/services/editions/db/CollectionsQueries.scala |
Updates renamed database references. |
app/model/packages/PackageMetadata.scala |
Defines package metadata. Critical, 3 votes: optional schemas with ignored unknown fields make web metadata parse as Feast metadata. |
app/model/packages/PackageCardType.scala |
Defines card types. Critical, 1 vote: the toString declaration requires override. |
app/model/packages/PackageCardRow.scala |
Defines persisted package cards. |
app/model/packages/PackageCard.scala |
Defines package card models. |
app/model/packages/Package.scala |
Defines the package model. |
app/model/packages/MetadataHelpers.scala |
Provides metadata conversion helpers. |
app/model/packages/client/UpdateRegionsRequest.scala |
Defines region update requests. |
app/model/packages/client/PatchContentRequest.scala |
Defines content patch operations. |
app/model/packages/client/CreatePackageRequest.scala |
Defines package creation requests. |
app/model/packages/client/ClientPackageHeader.scala |
Defines package list responses. |
app/model/packages/client/ClientPackageCard.scala |
Converts package cards for API use. |
app/model/packages/client/ClientPackage.scala |
Defines package API payloads. |
app/model/forms/GetPackagesFilter.scala |
Defines package filtering. |
app/controllers/V2App.scala |
Updates the database dependency type. |
app/controllers/PackageController.scala |
Implements package actions. Critical, 2 votes: remove operations are discarded, so content PATCH cannot delete cards. Moderate, 3 votes: invalid IDs broaden the query to all packages. Moderate, 3 votes: full=true performs up to 500 extra queries. Moderate, 1 vote: internal exception details are exposed. Moderate, 1 vote: list errors expose exception messages. Moderate, 1 vote: create errors expose exception messages. Moderate, 1 vote: date and limit parsing and validation occur outside safe handling. Critical, 1 vote: logger.logger does not compile. Moderate, 2 votes: valid date-only input cannot be parsed as OffsetDateTime. Moderate, 2 votes: malformed or negative limits are not safely handled. Moderate, 1 vote: region updates mutate story packages unexpectedly. Moderate, 1 vote: metadata updates do not return 404 for unknown packages. Moderate, 1 vote: name updates return 500 instead of 404 for unknown packages. |
app/controllers/EditionsController.scala |
Updates the database dependency type. |
app/Components.scala |
Wires package services and controllers. |
Review details
Suppressed comments (11)
app/controllers/PackageController.scala:141
- This internal exception message is returned to the client, potentially exposing SQL/schema or other implementation details. Keep the exception in logs and return a stable generic detail to callers.
"detail" -> JsString(
err.getMessage
) // TODO - tighten this up when we are done testing
app/controllers/PackageController.scala:124
- The list error path independently returns
err.getMessage, so database and serialization failures can expose internal details even whengenericErrorHandleris corrected. Return a fixed error detail here as well.
"status" -> JsString("error"),
"detail" -> JsString(
err.getMessage
) // TODO - tighten this up when we are done testing
app/controllers/PackageController.scala:208
- The create error path also sends
err.getMessagein a 500 response, which can expose internal database or application details. Return a stable generic detail and retain the exception only in the server log.
"status" -> JsString("error"),
"detail" -> JsString(
err.getMessage
) // TODO - tighten this up when we are done testing
app/controllers/PackageController.scala:66
- The
dateandlimitquery values are parsed before thetryblock, so malformed input such aslimit=abcor an invalid date escapes the action instead of returning a 400; negative limits are also not rejected before reaching SQL. Parse these values safely and validate their range before querying.
val maybeDate =
req.getQueryString("date").map(OffsetDateTime.parse(_, dateFormatter))
val strictDate = req.getQueryString("strict").isDefined
val maybeTitleSearch = req.getQueryString("title")
val limit = req.getQueryString("limit").map(_.toInt).getOrElse(200)
app/controllers/PackageController.scala:334
- The documented behavior says region updates are a no-op for Story Packages, but when
feastMetadatais absent this fallback creates a newFeastPackageMetadataand writes it tofeast_metadata. A story package will therefore be mutated and change type; detect the non-Feast case and return without updating (or reject it).
newMeta = maybeUpdate.getOrElse(
FeastPackageMetadata(
excludedRegions = req.body.excludedRegions,
targetedRegions = req.body.targetedRegions
)
),
app/controllers/PackageController.scala:243
updatePackageMetareturns the affected-row count, but it is ignored here, so aPUT /packages/:id/metadatafor an unknown package returns 204 instead of the 404 used by the other package update handlers. Check the count and returnNotFoundwhen it is zero.
db.updatePackageMeta(id, req.body, req.user.username, req.user.email)
NoContent
app/controllers/PackageController.scala:286
- For a nonexistent package,
updatePackageNameupdates zero rows, then the assertion fails and the controller converts it into a 500 response. The other package update endpoints return 404 for an unknown ID, so check the update count and map this case toNotFound.
db.updatePackageName(
id,
decoder.decode(req.body.asByteBuffer).toString,
req.user.username,
req.user.email
app/services/editions/db/PackageQueries.scala:133
- The API documentation says this handler returns
410 Conflict, but the controller'spsqlErrorHandlerreturns HTTP 409 for conflicts. Update the documentation to avoid giving clients the wrong status code.
* controller catches this with `psqlErrorHandler` and returns a 410 Conflict
conf/logback.xml:22
- This switches stdout from the Logstash encoder to a plain text encoder even though the application emits structured markers (for example via
StructuredLogger). Any production log ingestion expecting JSON fields will lose them. Keep the structured encoder for production or make the format environment-specific.
<encoder>
<pattern>%date [%thread] %-5level %logger{36} - %msg%n%xException{3}</pattern>
</encoder>
<!-- <encoder class="net.logstash.logback.encoder.LogstashEncoder" />-->
docs/Packages API.md:24
- Correct the typo in the documentation:
Editiorialshould beEditorial.
deliberately broad in order that MRR and Editiorial can use them however they want.
docs/Packages API.md:40
targettingis misspelled; usetargeting.
Packages and effectively a no-op for Story Packages, since they do not have the relevant targetting fields
- Files reviewed: 36/36 changed files
- Comments generated: 19
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| override def reads(json: JsValue): JsResult[PackageMetadata] = { | ||
| FeastPackageMetadata.format.reads(json) orElse WebPackageMetadata.format | ||
| .reads(json) |
There was a problem hiding this comment.
This seems like valid feedback from copilot
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical and moderate defects remain in package operations, SQL queries, error handling, metadata handling, and client types.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (8)
Previously missed (4) — in code that hasn't changed since the last review.
app/controllers/PackageController.scala:263
updatePackageMetareturns the affected-row count, but this action discards it and always returns204. A valid UUID for a missing package is therefore reported as a successful update; check the count and return404, as the hidden-state and region endpoints do.
app/services/editions/db/PackageQueries.scala:128- For a nonexistent package, the UPDATE affects zero rows and the subsequent fetch is empty, so this assertion throws.
PackageController.updateNamecatches thatThrowableand returns500instead of404; return an explicit not-found result (or an affected-row/Option result) for this case.
app/services/editions/db/PackageQueries.scala:509 - The documented and tested query parameter is
order=title, but this parser only recognizesname.order=titletherefore silently falls back tocreated_onrather than sorting by package name; accept thetitlealias or change the public contract.
docs/Packages API.md:24 - Typo:
Editiorialshould beEditorial.
app/controllers/PackageController.scala:463
- The result of
updatePackageContentis the affected-row count, but it is discarded here. An empty patch for a nonexistent package therefore returns 200/status: okinstead of 404; use the count to handle the missing package consistently with the other mutation endpoints.
db.updatePackageContent(
packageId,
adds,
removes,
req.user.username,
req.user.email
)
app/model/packages/PackageMetadata.scala:53
- Because every Feast metadata field is optional and Play JSON ignores unknown fields, a Web-shaped payload such as
{ "headline": "..." }successfully reads as an emptyFeastPackageMetadata. ConsequentlyPUT /packages/:id/metadataalways selectsfeast_metadatafor Web payloads, soWebPackageMetadatais never reachable. Add an explicit discriminator or strict shape detection before selecting the subtype.
override def reads(json: JsValue): JsResult[PackageMetadata] = {
FeastPackageMetadata.format.reads(json) orElse WebPackageMetadata.format
.reads(json)
app/services/editions/db/PackageQueries.scala:348
atIndexis client-controlled, butListBuffer.insertrequires an index between zero and the current list length. An out-of-range PATCH therefore throwsIndexOutOfBoundsExceptionand is returned as a generic 500 instead of a validation error; reject invalid indices before reindexing.
packageCards.foreach(card =>
intermediateIndices.insert(card.index, (card.pageCode, card.index))
)
fronts-client/src/types/FaciaApi.ts:171
- Recipe cards omit
metadatafrom the TypeScript interface, although the backendClientPackageCardaccepts and returns it and the package protocol includes metadata on recipe items. TypeScript consumers cannot type a valid recipe card carrying metadata; add the optional field to this variant.
- Files reviewed: 39/40 changed files
- Comments generated: 7
- Review effort level: Lite
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
| final case class PackageCardRow( | ||
| packageId: String, | ||
| cardType: PackageCardType, | ||
| pageCode: String, // CAPI internalPageCode of an article. Either the recipe ID, the chef ID or the subcollection ID if this is a Feast card. |
There was a problem hiding this comment.
Why is this called pageCode when it is only a pageCode for articles?
There was a problem hiding this comment.
to follow the naming convention used elsewhere. We can change it to something more generic, but I started off trying to keep the models as close as possible
| @@ -0,0 +1,17 @@ | |||
| package model.forms | |||
There was a problem hiding this comment.
I think you're right, i'll zap it if unused
| web_metadata JSONB, | ||
| feast_metadata JSONB, |
There was a problem hiding this comment.
Would it make more sense to have a single JSONB metadata, and a separate field to indicate the package type?
| is_hidden BOOLEAN NOT NULL, | ||
| web_metadata JSONB, | ||
| feast_metadata JSONB, | ||
| prefill TEXT, |
There was a problem hiding this comment.
prefill is copied over from the existing Editions model. In an article container I believe it's a CAPI query to programatically fill the container and in Feast it would be used as a search to prefill the container. I can remove it if you'd rather as it doesn't have a wired in use at the moment
There was a problem hiding this comment.
Fair enough, I know this as backfill
| private def psqlErrorHandler(err: PSQLException) = | ||
| err.getSQLState match { | ||
| // See https://www.postgresql.org/docs/current/errcodes-appendix.html for a list of codes | ||
| case "23505" => // unique constraint violation |
There was a problem hiding this comment.
Perhaps case s if s == PSQLState.UNIQUE_VIOLATION.getState => to avoid a magic number?
| import cats.instances.option._ // Provides Traverse[Option] | ||
|
|
||
| val idList = req | ||
| .getQueryString("id") |
There was a problem hiding this comment.
Instead of accessing getQueryString you could make the capabilities of this endpoint obvious in the routes file by passing the query string values as parameter values to listPackages (https://www.playframework.com/documentation/3.0.x/ScalaRouting#Call-to-the-Action-generator-method)
e.g. listPackages(id: Option[String],...
Co-authored-by: David Furey <david.furey@guardian.co.uk>
Co-authored-by: David Furey <david.furey@guardian.co.uk>
|
|
||
| if (limit > 500) { | ||
| BadRequest( | ||
| Json.obj("status" -> "error", "detail" -> "limit is too large") |
There was a problem hiding this comment.
You've used this enough to make it a case class with a json formatter
| try { | ||
| val pkgs = db.getPackages( | ||
| idList, | ||
| maybeDate.get, // safe because the failure case is already handled above |
There was a problem hiding this comment.
It would be better if the code was structured so that the compiler was making that guarantee
| case err: PSQLException => | ||
| logger.error(s"Could not list packages: ${err.getMessage}", err) | ||
| psqlErrorHandler(err) | ||
| case err: Throwable => | ||
| logger.error(s"Could not list packages: ${err.getMessage}", err) | ||
| InternalServerError( | ||
| Json.obj( | ||
| "status" -> JsString("error"), | ||
| "detail" -> JsString( | ||
| err.getMessage | ||
| ) // TODO - tighten this up when we are done testing | ||
| ) | ||
| ) |
There was a problem hiding this comment.
Do you really want any package have a failure to cause the whole request to fail?
What's changed?
Adds a new backend feature to support collections that do not belong to any front at all - i.e., packages.
The intention is to support Feast's upcoming "Guardian Selections" feature - here referred to more prosaically as "recipe packages". As discussed with @davidfurey et. al., it's been written in such a way as to be easily extendable for a future port of Story Packages.
Implementation notes
This PR implements a number of endpoints under
/packagesinspired by the way the Editions endpoints work - see ./doc/Packages%20Endpoints.md. The only frontend changes are Typescript definitions for the core API data types.Packages live in the Postgres database - the core class has been renamed to
FaciaDBfromEditionsDBsince it no longer just serves Editions (and Editions no longer exist as a product).How to test
You can exercise the package endpoints locally or in CODE, once it is deployed; authentication is handled by logging in with the browser then copying your guAuth-assum cookie into the request.
You need to make sure you have the "Edit fronts packages" permission enabled in Permissions tool for your account otherwise your requests will be rejected.
You can use any REST client of your choice, though the detailed runthrough shows
curlDrop down to see a full test protocol
Preconditions
EditEditionsAuthAction).0) Prefill a package fixture and cards
Create a baseline package (acts as fixture). Use
POST /packages.{ "id": "${PREFILLED_PACKAGE_ID}", "name": "Prefilled package", "isHidden": false, "webMetadata": { "source": "manual-protocol" }, "feastMetadata": { "targetedRegions": ["uk"], "excludedRegions": ["us"] }, "prefill": "recipes", "createdOn": ${NOW_MS}, "createdBy": "Manual Tester", "createdEmail": "manual.tester@guardian.co.uk" }Expected:
201 CreatedAdd cards by overwriting the package with
PUT /packages/:id(this is the supported endpoint-level way to set package items):{ "id": "${PREFILLED_PACKAGE_ID}", "name": "Prefilled package", "isHidden": false, "webMetadata": { "source": "manual-protocol" }, "feastMetadata": { "targetedRegions": ["uk"], "excludedRegions": ["us"] }, "prefill": "recipes", "createdOn": ${NOW_MS}, "createdBy": "Manual Tester", "createdEmail": "manual.tester@guardian.co.uk", "updatedOn": ${NOW_MS}, "updatedBy": "Manual Tester", "updatedEmail": "manual.tester@guardian.co.uk", "items": [ { "id": "recipe-10001", "cardType": "recipe", "addedOn": ${NOW_MS}, "metadata": {"slot": 0} }, { "id": "recipe-10002", "cardType": "recipe", "addedOn": ${NOW_MS}, "metadata": {"slot": 1} }, { "id": "recipe-10003", "cardType": "recipe", "addedOn": ${NOW_MS}, "metadata": {"slot": 2} } ] }Expected:
200 OKid,name, anditemslength31) Create a new package with POST
POST /packages{ "id": "${CREATED_PACKAGE_ID}", "name": "Created package", "isHidden": false, "webMetadata": { "source": "manual-create" }, "feastMetadata": { "targetedRegions": ["gb"], "excludedRegions": ["us"] }, "prefill": "recipes", "createdOn": ${NOW_MS}, "createdBy": "Manual Tester", "createdEmail": "manual.tester@guardian.co.uk" }Expected:
201 Created2) Get the prefilled package by ID
GET /packages/${PREFILLED_PACKAGE_ID}Expected:
200 OKid == PREFILLED_PACKAGE_IDname == "Prefilled package"itemsarray exists and contains 3 elements3) List available packages
GET /packagesExpected:
200 OKstatus == "ok"packagesis an array containing at least both package IDs from this protocol4) Verify sort order params and title search
4a. Sort by created time
GET /packages?order=createdExpected:
200 OKpackagesarray returned, newest first by created timestamp4b. Sort by updated time
GET /packages?order=updatedExpected:
200 OKpackagesarray returned, newest first by updated timestamp4c. Search by title/name
GET /packages?order=title&title=CreatedExpected:
200 OKpackagesarray where entries match name search (containsCreated package)5) Overwrite an existing package with PUT
PUT /packages/${PREFILLED_PACKAGE_ID}{ "id": "${PREFILLED_PACKAGE_ID}", "name": "Prefilled package overwritten", "isHidden": false, "webMetadata": { "headline": "Updated by overwrite" }, "feastMetadata": { "targetedRegions": ["au", "uk"], "excludedRegions": ["us"] }, "prefill": "recipes", "createdOn": ${NOW_MS}, "createdBy": "Manual Tester", "createdEmail": "manual.tester@guardian.co.uk", "updatedOn": ${NOW_MS}, "updatedBy": "Manual Tester", "updatedEmail": "manual.tester@guardian.co.uk", "items": [ { "id": "recipe-20001", "cardType": "recipe", "addedOn": ${NOW_MS}, "metadata": {"slot": 0} }, { "id": "recipe-20002", "cardType": "recipe", "addedOn": ${NOW_MS}, "metadata": {"slot": 1} } ] }Expected:
200 OKname == "Prefilled package overwritten"itemscontains exactly the two overwritten cards6) Update package name only with PATCH
PATCH /packages/${PREFILLED_PACKAGE_ID}/nameBody is plain text (not JSON):
Expected:
204 No ContentGET /packages/:idshowsname == "Renamed with patch"7) Update feast-metadata regions only with PATCH
PATCH /packages/${PREFILLED_PACKAGE_ID}/update-regions{ "targetedRegions": ["eu", "uk"], "excludedRegions": ["us"] }Expected:
204 No ContentGET /packages/:idshowsfeastMetadata.targetedRegions == ["eu", "uk"]8) Update hidden flag only with PUT is-hidden
PUT /packages/${PREFILLED_PACKAGE_ID}/is-hidden/trueExpected:
204 No ContentGET /packages/:idshowsisHidden == true9) Update metadata only with PUT metadata
PUT /packages/${PREFILLED_PACKAGE_ID}/metadataUse a Feast metadata body (this endpoint accepts
PackageMetadata, either Feast or Web shape):{ "targetedRegions": ["eu"], "excludedRegions": ["us"] }Expected:
204 No ContentGET /packages/:idconfirms metadata changed and package name remains unchangedExample curl pattern
Use this shape for each call (replace method/path/body):
For the name patch endpoint, send plain text body and
Content-Type: text/plain; charset=utf-8.Checklist
General
Client
No obvious console errors on the client (i.e. React dev mode errors)n/aNo regressions with existing user interactions (i.e. all existing buttons, inputs etc. work)n/aScreenshots / GIFs of relevant UI changes includedn/a