Files

85 lines
3.2 KiB
TypeScript

// The public API of the branding module (plan item AT2.6, spec §10.3): an organization's own brand
// -- its display name, two colours and two logos -- as the end customers of the app see it. It is a
// layer over the app brand (config/src/brand.ts) and never replaces it: layout, type and the neutral
// tokens stay the app's, so one tenant cannot break the app's accessibility.
//
// The data is `organizations.branding`, a column of the tenancy module's own table; this module
// writes it through the tenancy module's entity and owns no table of its own.
import { LOGO_UPLOAD_LIMIT_BYTES } from "@conusai-kit/brand/logo/check"
import { procedure } from "@conusai-kit/core"
import { z } from "zod"
import { LOGO_SIZES } from "./logo.js"
import { brandViewSchema, LOGO_VARIANTS } from "./view.js"
export { LOGO_SIZES, type LogoChoice, type LogoSize, logoUrl, resolveLogo, tenantLogo } from "./logo.js"
export { type BrandView, brandViewSchema, LOGO_VARIANTS, type LogoVariant } from "./view.js"
/** The audit event of every change: one per call, whether a colour, a name or a logo. */
export const BRAND_CHANGED = "branding.brand_changed"
const inOrg = { org: z.string() }
export const brandGet = procedure("branding.brand.get")
.kind("read")
.scope("org")
.permission("branding.brand.read")
.input(z.object(inOrg))
.output(brandViewSchema)
.contract()
export const brandSet = procedure("branding.brand.set")
.kind("write")
.scope("org")
.permission("branding.brand.write")
.input(
z.object({
...inOrg,
// Absent leaves a field as it is; null clears it; a value sets it.
displayName: z.string().max(60).nullable().optional(),
primary: z.string().max(32).nullable().optional(),
accent: z.string().max(32).nullable().optional(),
}),
)
.output(brandViewSchema)
.audit(BRAND_CHANGED)
.contract()
export const logoSet = procedure("branding.logo.set")
.kind("effect")
.scope("org")
.permission("branding.brand.write")
.input(
z.object({
...inOrg,
variant: z.enum(LOGO_VARIANTS),
contentType: z.string().max(64),
/** The image, standard base64: a PNG or WebP up to the upload limit (it is resized), an SVG up to the stored one. */
body: z.string().max(Math.ceil((LOGO_UPLOAD_LIMIT_BYTES * 4) / 3) + 4),
}),
)
.output(brandViewSchema)
.audit(BRAND_CHANGED)
.contract()
// Removing a logo deletes its stored files, which only an effect may do, so it is not a field of `brand.set`.
export const logoClear = procedure("branding.logo.clear")
.kind("effect")
.scope("org")
.permission("branding.brand.write")
.input(z.object({ ...inOrg, variant: z.enum(LOGO_VARIANTS) }))
.output(brandViewSchema)
.audit(BRAND_CHANGED)
.contract()
export { tenantStyle } from "./style.js"
export const logoGet = procedure("branding.logo.get")
.kind("effect")
.scope("org")
.permission("branding.brand.read")
// `size` picks the 1x or the 2x rendition of a raster logo (2x when absent); an SVG is one file and has one size.
.input(z.object({ ...inOrg, variant: z.enum(LOGO_VARIANTS), size: z.enum(LOGO_SIZES).optional() }))
.output(z.object({ contentType: z.string(), body: z.string() }))
.contract()