Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 200 additions & 0 deletions apps/backend/app.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2615,6 +2615,206 @@ paths:
'404':
description: Artist not found

/library/crossreferences/artists:
get:
summary: List the legacy artist-to-artist cross-references
description: >
Successor to `/wxycdb`'s `xrefsToLibraryCodes.jsp` — the whole
`artist_crossreference` collection, the "see also" pointers a librarian
filed between two artist cards. READ ONLY, deliberately: WXYC/wiki#89's
decision D5 freezes this set at the tubafrenzy cutover ("post-cutover
artists don't gain aliases; unfreezing is a future ticket"), so there is
no create, update, or delete sibling and adding one would unfreeze it.


Enforced by `requirePermissions({ catalog: ['write'] })` — musicDirector
or above. A READ at the write tier, matching
`/library/bmi-performance-list`: `mainmenu.jsp` wraps this link in
`<c:if test="${user.hasAdminAccess()}">`, unlike the Missing Releases
and rotation links beside it, and `catalog:write` is the grant that
selects the same musicDirector + stationManager pair that flag names.


`target_code_artist_number` is genre-scoped and collapses to the lowest
`genre_id`, matching `GET /library/artists/{id}`; it is null for an
artist with no `genre_artist_crossreference` row. Only the TARGET
carries a call number, matching the JSP's columns. Ordered by the
cross-referencing artist's name, then both FK columns — the table has no
primary key, so the FK pair is what makes the order total and the pages
stable.


Known divergence from the JSP: it renders a "Time Last Modified" column
and NEITHER cross-reference table has a timestamp column. The field is
omitted here rather than invented.
security:
- BearerAuth: ['catalog-write']
parameters:
- in: query
name: page
required: false
description: Zero-based page index.
schema:
type: integer
minimum: 0
default: 0
- in: query
name: limit
required: false
description: >
Defaults above the whole frozen collection (119 rows at most) so a
caller can fetch it in one request.
schema:
type: integer
minimum: 1
maximum: 500
default: 200
responses:
'200':
description: >
One page of artist cross-references. An empty collection is a 200
with `total: 0` — the JSP's "There are no Library Code
Cross-References" state — not a 404.
content:
application/json:
schema:
type: object
properties:
results:
type: array
items:
type: object
properties:
source_artist_id:
type: integer
source_artist_name:
type: string
target_artist_id:
type: integer
target_artist_name:
type: string
target_code_letters:
type: string
target_code_artist_number:
type: integer
nullable: true
comment:
type: string
nullable: true
total:
type: integer
page:
type: integer
totalPages:
type: integer
'400':
description: Invalid page or limit

/library/crossreferences/releases:
get:
summary: List the legacy artist-to-release cross-references
description: >
Successor to `/wxycdb`'s `xrefsToLibraryReleases.jsp` — the whole
`artist_library_crossreference` collection, the pointers a librarian
filed from an artist card to a specific release. READ ONLY for the same
reason as its artist sibling; WXYC/wiki#89 D5 drops this set rather than
freezing it, which is a still stronger argument against a write path.


Enforced by `requirePermissions({ catalog: ['write'] })` — musicDirector
or above, same `hasAdminAccess()` reasoning as the sibling above.


Each row carries TWO artists: `artist_id`/`artist_name` is the
cross-REFERENCING artist, `album_artist_name` is whoever the
cross-referenced release is filed under. They are usually different —
that difference is the association the row records. The call number
belongs to the release and ships as PARTS (`code_letters` +
`code_artist_number` + `code_number` + `code_volume_letters`), matching
`GET /library/artists/{id}/releases`, not as a composed string.
`code_artist_number` is genre-scoped (hence the per-row `genre_id`) and
is null when the release's artist has no `genre_artist_crossreference`
row for that genre — LEFT joined so a frozen legacy row can never
silently vanish from a listing nothing else reproduces.


Known divergence from the JSP: no "Time Last Modified" — the table has
no timestamp column.
security:
- BearerAuth: ['catalog-write']
parameters:
- in: query
name: page
required: false
description: Zero-based page index.
schema:
type: integer
minimum: 0
default: 0
- in: query
name: limit
required: false
schema:
type: integer
minimum: 1
maximum: 500
default: 200
responses:
'200':
description: >
One page of release cross-references. An empty collection is a 200
with `total: 0` — the JSP's "There are no Library Release
Cross-References" state — not a 404.
content:
application/json:
schema:
type: object
properties:
results:
type: array
items:
type: object
properties:
artist_id:
type: integer
artist_name:
type: string
library_id:
type: integer
album_title:
type: string
album_artist_name:
type: string
nullable: true
alternate_artist_name:
type: string
nullable: true
format_name:
type: string
genre_id:
type: integer
code_letters:
type: string
code_artist_number:
type: integer
nullable: true
code_number:
type: integer
code_volume_letters:
type: string
nullable: true
comment:
type: string
nullable: true
total:
type: integer
page:
type: integer
totalPages:
type: integer
'400':
description: Invalid page or limit

/library/formats:
get:
summary: Get format list
Expand Down
95 changes: 95 additions & 0 deletions apps/backend/controllers/library.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -906,6 +906,101 @@
res.status(200).json({ artist_id: artistId, releases, total, page, totalPages: Math.ceil(total / limit) });
};

/**
* Page bounds for the two cross-reference collections.
*
* NOT `DEFAULT_LIMIT`/`MAX_LIMIT` (50/100), which the catalog-search endpoints
* share. Those cap an open-ended catalog; these cap two frozen legacy tables
* that hold 78 and 22 rows on prod (WXYC/wiki#89's 2026-08-11 measurement),
* with `artist_crossreference` rising to at most 119 once
* `scripts/audit/bs_2117_crossref_backfill.sql` has loaded the resolvable
* pairs. A 100-row ceiling would make the artist collection permanently
* un-fetchable in one request for the sake of a cap that never binds, so the
* default is set above the whole frozen set and the maximum a few multiples
* beyond it. The cap still exists rather than the endpoint serving the table
* whole: `jobs/library-etl` keeps upserting into both on a 30-minute cron
* until the tubafrenzy cutover, and the freeze is a decision, not something
* the query can enforce.
*/
const CROSSREFERENCE_DEFAULT_LIMIT = 200;
const CROSSREFERENCE_MAX_LIMIT = 500;

type CrossReferenceQueryParams = { page?: string; limit?: string };

/**
* Parse `?page=`/`?limit=` for the two cross-reference listings.
*
* Same rules as `getArtistReleases` and `searchLibraryQueryEndpoint` — a
* repeated key is a 400 rather than a silent coercion, because Express's
* `simple` query parser yields `string[]` and `parseInt(['1','2'])`
* stringifies to `'1,2'` and returns `1` (#1553). Factored out here because
* two handlers need it identically; the existing callers keep their inline
* copies, which validate against different bounds.
*/
const parseCrossReferencePage = (query: CrossReferenceQueryParams): { page: number; limit: number } => {
if (query.page !== undefined && typeof query.page !== 'string') {
throw new WxycError('page must be a single string value', 400);
}
const page = parseInt(query.page ?? '0');
if (isNaN(page) || page < 0) {
throw new WxycError('page must be a non-negative integer', 400);
}

if (query.limit !== undefined && typeof query.limit !== 'string') {
throw new WxycError('limit must be a single string value', 400);
}
const limit = parseInt(query.limit ?? String(CROSSREFERENCE_DEFAULT_LIMIT));
if (isNaN(limit) || limit < 1) {
throw new WxycError('limit must be a positive integer', 400);
}
if (limit > CROSSREFERENCE_MAX_LIMIT) {
throw new WxycError(`limit must not exceed ${CROSSREFERENCE_MAX_LIMIT}`, 400);
}

return { page, limit };
};

/**
* GET /library/crossreferences/artists — the whole `artist_crossreference`
* collection, successor to `/wxycdb`'s `xrefsToLibraryCodes.jsp`.
*
* Read-only by decision, not by omission: WXYC/wiki#89 D5 freezes this set at
* the tubafrenzy cutover, so there is no POST/PATCH/DELETE sibling and adding
* one would unfreeze it. An empty collection is a 200 with `total: 0`, which
* is what the JSP's "There are no Library Code Cross-References" state
* renders from — not a 404, since the collection exists and is empty.
*/
export const listArtistCrossReferences: RequestHandler<object, unknown, unknown, CrossReferenceQueryParams> = async (
req,
res
) => {
const { page, limit } = parseCrossReferencePage(req.query);
const [results, total] = await Promise.all([
libraryService.getArtistCrossReferences(page, limit),
libraryService.countArtistCrossReferences(),
]);
res.status(200).json({ results, total, page, totalPages: Math.ceil(total / limit) });
};

/**
* GET /library/crossreferences/releases — the whole
* `artist_library_crossreference` collection, successor to `/wxycdb`'s
* `xrefsToLibraryReleases.jsp`. Read-only for the same reason as its sibling
* above; D5 drops this set rather than freezing it, which is a still stronger
* argument against a write path.
*/
export const listReleaseCrossReferences: RequestHandler<object, unknown, unknown, CrossReferenceQueryParams> = async (
req,
res
) => {
const { page, limit } = parseCrossReferencePage(req.query);
const [results, total] = await Promise.all([
libraryService.getReleaseCrossReferences(page, limit),
libraryService.countReleaseCrossReferences(),
]);
res.status(200).json({ results, total, page, totalPages: Math.ceil(total / limit) });
};

/**
* Validate one optional free-text body field: must be a string, must not be
* blank after trimming, must fit the column. Returns the trimmed value.
Expand Down Expand Up @@ -1562,13 +1657,13 @@
};

export const addFormat: RequestHandler = async (req, res) => {
const { body } = req;

Check warning on line 1660 in apps/backend/controllers/library.controller.ts

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

Unsafe object destructuring of a property with an `any` value
if (body.name === undefined) {

Check warning on line 1661 in apps/backend/controllers/library.controller.ts

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

Unsafe member access .name on an `any` value
throw new WxycError('Bad Request, Missing Parameter: name', 400);
}

const newFormat: NewAlbumFormat = {
format_name: body.name,

Check warning on line 1666 in apps/backend/controllers/library.controller.ts

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

Unsafe member access .name on an `any` value

Check warning on line 1666 in apps/backend/controllers/library.controller.ts

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

Unsafe assignment of an `any` value
};

const insertion = await libraryService.insertFormat(newFormat);
Expand All @@ -1581,8 +1676,8 @@
};

export const addGenre: RequestHandler = async (req, res) => {
const { body } = req;

Check warning on line 1679 in apps/backend/controllers/library.controller.ts

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

Unsafe object destructuring of a property with an `any` value
if (body.name === undefined || body.description === undefined) {

Check warning on line 1680 in apps/backend/controllers/library.controller.ts

View workflow job for this annotation

GitHub Actions / lint-and-typecheck

Unsafe member access .name on an `any` value
throw new WxycError('Bad Request, Parameters name and description are required.', 400);
}

Expand Down
33 changes: 33 additions & 0 deletions apps/backend/routes/library.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,39 @@ library_route.get(
libraryController.exportBmiPerformanceList
);

// The two legacy cross-reference listings — successors to `/wxycdb`'s
// `xrefsToLibraryCodes.jsp` and `xrefsToLibraryReleases.jsp`. Read-only: both
// sets are frozen (artist codes) or dropped (releases) by WXYC/wiki#89's
// decision D5, so no write sibling belongs on this router.
//
// `catalog: ['write']` on a READ, deliberately, and for the same reason
// `/bmi-performance-list` above carries it: `mainmenu.jsp:32-38` wraps both of
// these links in `<c:if test="${user.hasAdminAccess()}">`, unlike Missing
// Releases and the rotation links right below them, which sit outside it. That
// flag is tubafrenzy's librarian/MD tier, and `catalog: ['write']` is the
// grant that selects musicDirector + stationManager here — the same pair —
// without minting a statement key. `catalog: ['read']` would hand every DJ and
// `member` a screen the legacy system gated; a new `catalog: ['admin']`-style
// key would have to be decided for all four roles (auth.roles.ts) to express a
// tier that already exists. `/artists/search` and `/artists/peek-code` set the
// same precedent for an admin-gated read.
//
// Two literal segments, so neither collides with the templated
// `/:id/compilation-tracks` GET further down (different second segment) — but
// they are registered here, ahead of every templated route on this router,
// rather than relying on that.
library_route.get(
'/crossreferences/artists',
requirePermissions({ catalog: ['write'] }),
libraryController.listArtistCrossReferences
);

library_route.get(
'/crossreferences/releases',
requirePermissions({ catalog: ['write'] }),
libraryController.listReleaseCrossReferences
);

library_route.post('/', requirePermissions({ catalog: ['write'] }), libraryController.addAlbum);

library_route.get('/rotation', requirePermissions({ catalog: ['read'] }), libraryController.getRotation);
Expand Down
Loading
Loading