diff --git a/server/api.js b/server/api.js index e353150c..09326c61 100644 --- a/server/api.js +++ b/server/api.js @@ -738,6 +738,14 @@ router orgsAPI.getCampusAdmins ); +router + .route("/org/:orgID/custom-cover-config") + .get( + orgsAPI.validate("getCustomCoverConfig"), + middleware.checkValidationErrors, + orgsAPI.getCustomCoverConfig + ); + /* Asset Tag Frameworks */ router .route("/assettagframeworks") diff --git a/server/api/organizations.ts b/server/api/organizations.ts index c5238f9a..da53d8c6 100644 --- a/server/api/organizations.ts +++ b/server/api/organizations.ts @@ -6,7 +6,7 @@ 'use strict'; import logger from "../logger.js"; -import express, {Request, Response, NextFunction} from 'express'; +import express, { Request, Response, NextFunction } from 'express'; import { body, param, query } from 'express-validator'; import multer from 'multer'; import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; @@ -71,7 +71,7 @@ async function lookupOrganization(orgID: string) { { orgID }, { _id: 0, defaultProjectLead: 0 }, ).lean(); - if(org?.commonsModules){ + if (org?.commonsModules) { // Remove _id and __v fields from commonsModules subdocument // @ts-ignore delete org.commonsModules._id; @@ -225,6 +225,45 @@ async function getLibreGridOrganizations(_req: Request, res: Response) { } } +async function getCustomCoverConfig(req: Request, res: Response) { + try { + const { orgID } = req.params; + + const orgData = await Organization.findOne( + { + orgID: { $eq: orgID } + }, + { + orgID: 1, + name: 1, + customCoverConfig: 1, + } + ).lean(); + + if (!orgData) { + return res.status(404).send({ + err: true, + errMsg: conductorErrors.err11, + }); + } + + return res.send({ + err: false, + org: { + orgID: orgData.orgID, + name: orgData.name, + }, + customCoverConfig: orgData.customCoverConfig || null, + }); + } catch (e) { + logger.error({ err: e }, "getCustomCoverConfig failed"); + return res.status(500).send({ + err: true, + errMsg: conductorErrors.err6, + }); + } +} + /** * Updates an Organization's information. * @@ -248,11 +287,11 @@ async function updateOrganizationInfo(req: Request, res: Response) { } }; - if(req.body.primaryColor) { + if (req.body.primaryColor) { updateObj.primaryColor = sanitizeCustomColor(req.body.primaryColor) } - if(req.body.footerColor) { + if (req.body.footerColor) { updateObj.footerColor = sanitizeCustomColor(req.body.footerColor) } @@ -274,7 +313,7 @@ async function updateOrganizationInfo(req: Request, res: Response) { ); } - if(Object.hasOwn(updateObj, 'collectionsDisplayLabel') && isEmptyString(updateObj.collectionsDisplayLabel ?? '')){ + if (Object.hasOwn(updateObj, 'collectionsDisplayLabel') && isEmptyString(updateObj.collectionsDisplayLabel ?? '')) { // Reset label to 'Collections' if empty string was passed updateObj.collectionsDisplayLabel = 'Collections' } @@ -355,9 +394,9 @@ async function updateBrandingImageAsset(req: Request, res: Response) { errMsg: conductorErrors.err2, }); } - + let assetVersion = 1; - + // @ts-ignore if (org[assetName].includes(process.env.AWS_ORGDATA_DOMAIN)) { //@ts-ignore @@ -472,7 +511,7 @@ function validateCommonsModules(commonsModules: any) { return false; } for (const module in commonsModules) { - if(module === "_id" || module === "__v"){ + if (module === "_id" || module === "__v") { // ignore these fields (mongodb adds them because commonsModules is technically a subdocument of the Organization model) continue; } @@ -512,16 +551,20 @@ function validate(method: string) { return [ param('orgID', conductorErrors.err1).exists().isLength({ min: 2, max: 50 }), ]; + case 'getCustomCoverConfig': + return [ + param('orgID', conductorErrors.err1).exists().isLength({ min: 2, max: 50 }), + ]; case 'updateinfo': return [ param('orgID', conductorErrors.err1).exists().isLength({ min: 2, max: 50 }), body('aboutLink', conductorErrors.err1).optional({ checkFalsy: true }).isURL(), body('commonsHeader', conductorErrors.err1).optional({ checkFalsy: true }).isLength({ max: 200 }), body('commonsMessage', conductorErrors.err1).optional({ checkFalsy: true }).isLength({ max: 500 }), - body('collectionsDisplayLabel', conductorErrors.err1).optional({checkFalsy: true}).isLength({ max: 200 }), - body('collectionsMessage', conductorErrors.err1).optional({checkFalsy: true}).isLength({max: 500}), - body('primaryColor', conductorErrors.err1).optional({ checkFalsy: true }).isLength({min: 7, max: 7}).isHexColor(), - body('footerColor', conductorErrors.err1).optional({ checkFalsy: true }).isLength({min: 7, max: 7}).isHexColor(), + body('collectionsDisplayLabel', conductorErrors.err1).optional({ checkFalsy: true }).isLength({ max: 200 }), + body('collectionsMessage', conductorErrors.err1).optional({ checkFalsy: true }).isLength({ max: 500 }), + body('primaryColor', conductorErrors.err1).optional({ checkFalsy: true }).isLength({ min: 7, max: 7 }).isHexColor(), + body('footerColor', conductorErrors.err1).optional({ checkFalsy: true }).isLength({ min: 7, max: 7 }).isHexColor(), body('addToLibreGridList', conductorErrors.err1).optional({ checkFalsy: true }).isBoolean().toBoolean(), body('supportTicketNotifiers', conductorErrors.err1).optional({ checkFalsy: true }).isArray().isEmail(), body('defaultAssetTagFrameworkUUID', conductorErrors.err1).optional({ checkFalsy: true }).isUUID(), @@ -549,6 +592,7 @@ export default { getOrganizationInfo, getCampusAdmins, getCurrentOrganization, + getCustomCoverConfig, getAllOrganizations, getLibreGridOrganizations, updateOrganizationInfo, diff --git a/server/api/services/shapeshift-service.ts b/server/api/services/shapeshift-service.ts index 5b949255..00d4788b 100644 --- a/server/api/services/shapeshift-service.ts +++ b/server/api/services/shapeshift-service.ts @@ -157,6 +157,7 @@ export default class ShapeshiftService { bookID, contentPageCount, timestamp, + customCoverOrg, }: WebhookParams): Promise { const result = await Book.updateOne( { @@ -174,6 +175,7 @@ export default class ShapeshiftService { ...(contentPageCount !== undefined ? { "exportInfo.contentPageCount": contentPageCount } : {}), + ...(customCoverOrg !== undefined ? { "exportInfo.customCoverOrg": customCoverOrg } : {}), }, } ); diff --git a/server/api/validators/shapeshift.ts b/server/api/validators/shapeshift.ts index 22290f52..520bd0b7 100644 --- a/server/api/validators/shapeshift.ts +++ b/server/api/validators/shapeshift.ts @@ -38,5 +38,6 @@ export const WebhookValidator = z.object({ bookID: bookIDSchema, contentPageCount: z.number().int().nonnegative().optional(), timestamp: z.number().int().nonnegative(), // Unix timestamp in milliseconds + customCoverOrg: z.string().trim().min(1).max(255).optional(), // Only set if the book has a custom cover by the producing org }), }); diff --git a/server/models/book.ts b/server/models/book.ts index e2bcdec0..6f9b34f2 100644 --- a/server/models/book.ts +++ b/server/models/book.ts @@ -19,6 +19,7 @@ export interface BookInterface extends Document { lastCompiled?: number; compiledBy?: string; contentPageCount?: number; + customCoverOrg?: string; // The name of the org producing the book if a custom cover is used. Should be used for display/informational purposes only, not for load-bearing logic or validation. lastJobID?: string; lastJobSubmittedAt?: Date; lastJobSubmittedBy?: string; @@ -131,6 +132,10 @@ const BookSchema = new Schema( * The number of pages in the Book's content PDF, as reported by Shapeshift during compilation. */ contentPageCount: Number, + /** + * The name of the org producing the book if a custom cover is used. Should be used for display/informational purposes only, not for load-bearing logic or validation. + */ + customCoverOrg: String, /** * The Shapeshift job ID of the most recent compile submitted from Conductor. * diff --git a/server/models/organization.ts b/server/models/organization.ts index dc8b7c24..089bbbda 100644 --- a/server/models/organization.ts +++ b/server/models/organization.ts @@ -21,6 +21,17 @@ export type CommonsModuleSettings = { minirepos: CommonsModuleConfig; } +export type CustomCoverConfig = { + enabled: boolean; + casewrapCoverFrontTemplateURL: string; + casewrapCoverBackTemplateURL: string; + perfectboundCoverFrontTemplateURL: string; + perfectboundCoverBackTemplateURL: string; + spineHexColor: string; + spineImageURL?: string; + matchingPaths: string[]; +} + export interface OrganizationInterface extends Document { orgID: string; active: boolean; @@ -51,6 +62,7 @@ export interface OrganizationInterface extends Document { showCollections?: boolean; assetFilterExclusions?: string[]; autoCatalogMatchingDisabled?: boolean; + customCoverConfig?: CustomCoverConfig; listenerPriority: number; cpuUnitsOverride: number; memoryValueOverride: number; @@ -60,6 +72,30 @@ export interface OrganizationInterface extends Document { FEAT_RecordSearchQueries?: boolean; } +/** + * Custom cover configuration is optional as a whole, but every field except + * `spineImageURL` is required when the object is present. Declaring it as a + * single nested subdocument (rather than a nested object literal) means these + * validators only run when the subdocument actually exists. + */ +const CustomCoverConfigSchema = new Schema( + { + enabled: { type: Boolean, required: true }, + casewrapCoverFrontTemplateURL: { type: String, required: true }, + casewrapCoverBackTemplateURL: { type: String, required: true }, + perfectboundCoverFrontTemplateURL: { type: String, required: true }, + perfectboundCoverBackTemplateURL: { type: String, required: true }, + spineHexColor: { type: String, required: true }, + spineImageURL: String, + matchingPaths: { + type: [String], + required: true, + default: undefined, + }, + }, + { _id: false } +); + const OrganizationSchema = new Schema( { /** @@ -234,6 +270,13 @@ const OrganizationSchema = new Schema( type: Boolean, default: false, }, + /** + * Configuration for custom cover templates and spine colors for the Organization. + */ + customCoverConfig: { + type: CustomCoverConfigSchema, + required: false, + }, /** * Used for deterministic routing in a load-balanced Conductor deployment. */