This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The database-only brand use cases. This file is safe to import from Convex mutations; logo
|
||||
// decoding and ObjectStore effects live in brand.ts and the Convex Node action workflow.
|
||||
|
||||
import { isHexColor } from "@conusai-kit/brand"
|
||||
import { notFound, type TenantScope, validationFailed } from "@conusai-kit/core"
|
||||
import type { DataSource } from "@conusai-kit/data"
|
||||
import { brandingSchema, organizations } from "@conusai-kit/tenancy"
|
||||
import type { z } from "zod"
|
||||
import type { BrandView } from "../contract/view.js"
|
||||
|
||||
type StoredBrand = z.output<typeof brandingSchema>
|
||||
|
||||
export interface BrandChange {
|
||||
displayName?: string | null | undefined
|
||||
primary?: string | null | undefined
|
||||
accent?: string | null | undefined
|
||||
}
|
||||
|
||||
export const viewOf = (branding: StoredBrand | null): BrandView => ({
|
||||
displayName: branding?.displayName ?? null,
|
||||
primary: branding?.primary ?? null,
|
||||
accent: branding?.accent ?? null,
|
||||
logoLight: branding?.logoLight ?? null,
|
||||
logoDark: branding?.logoDark ?? null,
|
||||
})
|
||||
|
||||
const stored = (branding: StoredBrand): StoredBrand | null => (Object.keys(branding).length === 0 ? null : branding)
|
||||
|
||||
export async function getBrand(data: DataSource, scope: TenantScope): Promise<BrandView> {
|
||||
const org = await data.global(organizations).get(scope.orgId)
|
||||
if (!org) throw notFound("no such organization")
|
||||
return viewOf(org.branding)
|
||||
}
|
||||
|
||||
export async function setBrand(data: DataSource, scope: TenantScope, change: BrandChange): Promise<BrandView> {
|
||||
const problems: Array<{ path: string; message: string }> = []
|
||||
for (const field of ["primary", "accent"] as const) {
|
||||
const value = change[field]
|
||||
if (typeof value === "string" && !isHexColor(value))
|
||||
problems.push({ path: field, message: "is a hex colour such as #1a1a8c" })
|
||||
}
|
||||
if (typeof change.displayName === "string" && change.displayName.trim().length === 0) {
|
||||
problems.push({ path: "displayName", message: "is not empty; send null to clear it" })
|
||||
}
|
||||
if (problems.length > 0) throw validationFailed(problems, "this brand cannot be saved")
|
||||
|
||||
return data.unitOfWork.run(scope, async (tx) => {
|
||||
const org = await tx.global(organizations).get(scope.orgId)
|
||||
if (!org) throw notFound("no such organization")
|
||||
const next: Record<string, string | undefined> = { ...(org.branding ?? {}) }
|
||||
const set = (key: keyof StoredBrand, value: string | null | undefined) => {
|
||||
if (value === undefined) return
|
||||
if (value === null) delete next[key]
|
||||
else next[key] = value.trim()
|
||||
}
|
||||
set("displayName", change.displayName)
|
||||
set("primary", change.primary)
|
||||
set("accent", change.accent)
|
||||
const branding = stored(brandingSchema.parse(next))
|
||||
const updated = await tx.global(organizations).update(scope.orgId, { branding }, org.version)
|
||||
return viewOf(updated.branding)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
// The use cases of the branding module: read the organization's brand, change it, replace a logo,
|
||||
// and read a logo back. Everything a person may type is checked here, on the way in, because what is
|
||||
// stored is later written into a stylesheet and an image tag for every member of the organization.
|
||||
|
||||
import { type LogoRendition, logoRenditions } from "@conusai-kit/brand/logo"
|
||||
import { checkLogo, checkLogoUpload } from "@conusai-kit/brand/logo/check"
|
||||
import { conflict, notFound, type TenantScope, validationFailed } from "@conusai-kit/core"
|
||||
import type { DataSource, ObjectStore } from "@conusai-kit/data"
|
||||
import { brandingSchema, organizations } from "@conusai-kit/tenancy"
|
||||
import type { z } from "zod"
|
||||
import type { BrandView, LogoSize, LogoVariant } from "../contract/index.js"
|
||||
|
||||
export { type BrandChange, getBrand, setBrand } from "./brand-data.js"
|
||||
|
||||
export interface BrandingDeps {
|
||||
readonly data: DataSource
|
||||
readonly store: ObjectStore
|
||||
}
|
||||
|
||||
export type OrgScope = TenantScope
|
||||
|
||||
type StoredBrand = z.output<typeof brandingSchema>
|
||||
|
||||
const viewOf = (branding: StoredBrand | null): BrandView => ({
|
||||
displayName: branding?.displayName ?? null,
|
||||
primary: branding?.primary ?? null,
|
||||
accent: branding?.accent ?? null,
|
||||
logoLight: branding?.logoLight ?? null,
|
||||
logoDark: branding?.logoDark ?? null,
|
||||
})
|
||||
|
||||
const stored = (branding: StoredBrand): StoredBrand | null => (Object.keys(branding).length === 0 ? null : branding)
|
||||
|
||||
/** The 16-hex-character tag in a logo's path, which is also the middle of its storage keys. */
|
||||
const TAG = /[?&]v=([0-9a-f]{16})$/
|
||||
|
||||
/**
|
||||
* A raster logo is stored as its two renditions, `1x` and `2x` (PNGs, `@conusai-kit/brand/logo`), and never as it was
|
||||
* uploaded; an SVG is stored as uploaded, under `svg`, because the SVG rule refuses and never rewrites.
|
||||
*/
|
||||
type Kind = "1x" | "2x" | "svg"
|
||||
const KINDS: readonly Kind[] = ["1x", "2x", "svg"]
|
||||
const keyOf = (variant: LogoVariant, tag: string, kind: Kind): string => `branding/logo-${variant}-${tag}-${kind}`
|
||||
const fieldOf = (variant: LogoVariant): "logoLight" | "logoDark" => (variant === "light" ? "logoLight" : "logoDark")
|
||||
const kindOf = (rendition: LogoRendition): Kind => (rendition.scale === 1 ? "1x" : "2x")
|
||||
|
||||
async function tagOf(bytes: Uint8Array): Promise<string> {
|
||||
const copy = new Uint8Array(bytes.length)
|
||||
copy.set(bytes)
|
||||
const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", copy))
|
||||
return Array.from(digest.slice(0, 8), (b) => b.toString(16).padStart(2, "0")).join("")
|
||||
}
|
||||
|
||||
/** Deletes the stored files behind a logo path, whichever of them exist. A failure leaves an unreferenced file at worst, so it does not fail the change. */
|
||||
async function removeLogoFiles(
|
||||
deps: BrandingDeps,
|
||||
scope: OrgScope,
|
||||
variant: LogoVariant,
|
||||
path: string | undefined,
|
||||
): Promise<void> {
|
||||
const tag = path === undefined ? undefined : TAG.exec(path)?.[1]
|
||||
if (tag === undefined) return
|
||||
for (const kind of KINDS) await deps.store.delete(scope, keyOf(variant, tag, kind)).catch(() => undefined)
|
||||
}
|
||||
|
||||
/** Removes a logo: the row stops naming it, and then its stored files go (a leftover file is unreferenced, never served). */
|
||||
export async function clearLogo(deps: BrandingDeps, scope: OrgScope, variant: LogoVariant): Promise<BrandView> {
|
||||
const field = fieldOf(variant)
|
||||
const result = await deps.data.unitOfWork.run(scope, async (tx) => {
|
||||
const org = await tx.global(organizations).get(scope.orgId)
|
||||
if (!org) throw notFound("no such organization")
|
||||
const { [field]: previous, ...rest } = org.branding ?? {}
|
||||
const branding = stored(brandingSchema.parse(rest))
|
||||
const updated = await tx.global(organizations).update(scope.orgId, { branding }, org.version)
|
||||
return { view: viewOf(updated.branding), previous }
|
||||
})
|
||||
await removeLogoFiles(deps, scope, variant, result.previous)
|
||||
return result.view
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces a logo. The upload is checked by the kit's rules (`@conusai-kit/brand/logo`): a PNG or WebP that is one, up
|
||||
* to 2 MiB, or an SVG with nothing in it that can run or fetch, up to 256 KiB. A raster file is resized and re-encoded
|
||||
* into the 1x and 2x PNGs that are stored and served (each is checked again as a stored logo before it is put); an
|
||||
* SVG is stored as it came. A file the checks refuse, or the decoder cannot draw, is a 422 that names the rule.
|
||||
*/
|
||||
export async function setLogo(
|
||||
deps: BrandingDeps,
|
||||
scope: OrgScope,
|
||||
input: { variant: LogoVariant; contentType: string; bytes: Uint8Array },
|
||||
): Promise<BrandView> {
|
||||
// The reason names a rule, never a part of the file: a rejected upload is somebody's document.
|
||||
const refused = (reason: string): never => {
|
||||
throw validationFailed([{ path: "body", message: reason }], "this logo cannot be used")
|
||||
}
|
||||
const upload = checkLogoUpload(input.contentType, input.bytes)
|
||||
if (!upload.ok) return refused(upload.reason)
|
||||
|
||||
const files: Array<{ kind: Kind; contentType: string; bytes: Uint8Array }> = []
|
||||
if (upload.kind === "svg") files.push({ kind: "svg", contentType: "image/svg+xml", bytes: input.bytes })
|
||||
else {
|
||||
const made = await logoRenditions(input.contentType, input.bytes)
|
||||
if (!made.ok) return refused(made.reason)
|
||||
for (const rendition of made.renditions) {
|
||||
const verdict = checkLogo("image/png", rendition.png)
|
||||
if (!verdict.ok) return refused(verdict.reason)
|
||||
files.push({ kind: kindOf(rendition), contentType: "image/png", bytes: rendition.png })
|
||||
}
|
||||
}
|
||||
|
||||
// The tag is the content of what is stored (the largest file): the same image uploaded twice is the same logo.
|
||||
const tag = await tagOf((files[files.length - 1] as { bytes: Uint8Array }).bytes)
|
||||
const org = await deps.data.global(organizations).get(scope.orgId)
|
||||
if (!org) throw notFound("no such organization")
|
||||
for (const file of files) {
|
||||
await deps.store.put(scope, {
|
||||
key: keyOf(input.variant, tag, file.kind),
|
||||
body: file.bytes,
|
||||
contentType: file.contentType,
|
||||
})
|
||||
}
|
||||
|
||||
const field = fieldOf(input.variant)
|
||||
const path = `/${org.slug}/brand/logo/${input.variant}?v=${tag}`
|
||||
// The same image uploaded again produces the files the row already names: those are in use, and
|
||||
// a failed update below must not remove them.
|
||||
const inUse = TAG.exec(org.branding?.[field] ?? "")?.[1] === tag
|
||||
try {
|
||||
const result = await deps.data.unitOfWork.run(scope, async (tx) => {
|
||||
const current = await tx.global(organizations).get(scope.orgId)
|
||||
if (!current) throw notFound("no such organization")
|
||||
const branding = stored(brandingSchema.parse({ ...(current.branding ?? {}), [field]: path }))
|
||||
const updated = await tx.global(organizations).update(scope.orgId, { branding }, current.version)
|
||||
return { view: viewOf(updated.branding), previous: current.branding?.[field] }
|
||||
})
|
||||
// The replaced files go once the row no longer names them (and not when the same image was uploaded again).
|
||||
if (result.previous !== path) await removeLogoFiles(deps, scope, input.variant, result.previous)
|
||||
return result.view
|
||||
} catch (error) {
|
||||
// The row never named the new files (a version conflict, a lost connection): they are
|
||||
// unreferenced the moment they were put, so they go now rather than staying as orphans.
|
||||
if (!inUse) {
|
||||
for (const file of files) {
|
||||
await deps.store.delete(scope, keyOf(input.variant, tag, file.kind)).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A stored logo's bytes: the size asked for (2x by default), or the SVG when that is what the organization uploaded
|
||||
* (an SVG has one size, so either size answers with it).
|
||||
*/
|
||||
export async function readLogo(
|
||||
deps: BrandingDeps,
|
||||
scope: OrgScope,
|
||||
variant: LogoVariant,
|
||||
size: LogoSize = "2x",
|
||||
): Promise<{ contentType: string; bytes: Uint8Array }> {
|
||||
const org = await deps.data.global(organizations).get(scope.orgId)
|
||||
if (!org) throw notFound("no such organization")
|
||||
const path = org.branding?.[fieldOf(variant)]
|
||||
const tag = path === undefined ? undefined : TAG.exec(path)?.[1]
|
||||
if (tag === undefined) throw notFound("this organization has no such logo")
|
||||
for (const kind of [size, "svg"] as const) {
|
||||
const object = await deps.store.get(scope, keyOf(variant, tag, kind))
|
||||
if (!object) continue
|
||||
const chunks: Uint8Array[] = []
|
||||
const reader = object.body.getReader()
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
if (value) chunks.push(value)
|
||||
}
|
||||
const bytes = new Uint8Array(chunks.reduce((sum, chunk) => sum + chunk.length, 0))
|
||||
let at = 0
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, at)
|
||||
at += chunk.length
|
||||
}
|
||||
return { contentType: object.contentType, bytes }
|
||||
}
|
||||
throw conflict("the logo is recorded but its file is missing")
|
||||
}
|
||||
Reference in New Issue
Block a user