mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 09:30:59 +00:00
feat: WIP Contnet managemtn
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
import { SUPPORT_MEDIA_PREFIX, SupportDocSlug } from "@edr/types";
|
||||
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsIn,
|
||||
IsObject,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
|
||||
/**
|
||||
* Markdown bodies are safe on read — the portal renders them with
|
||||
* `react-markdown` and no `rehype-raw`, so any HTML in them is inert. The
|
||||
* fields worth validating are these: they land in `href`/`src` attributes and
|
||||
* bypass markdown entirely, which is where a `javascript:` URL would actually
|
||||
* execute.
|
||||
*
|
||||
* Placeholders survive the check because they sit after the scheme
|
||||
* (`mailto:{{supportEmail}}`, `tel:{{supportPhoneTel}}`).
|
||||
*/
|
||||
const LINK_PATTERN = /^(https?:\/\/|mailto:|tel:|\/)/;
|
||||
const LINK_MESSAGE =
|
||||
"$property must start with http(s)://, mailto:, tel: or /";
|
||||
|
||||
/**
|
||||
* A media source is either an uploaded MinIO object key, a same-origin path, or
|
||||
* an https URL. Anything else — notably `javascript:` — is refused, since this
|
||||
* value lands in an `<img>`/`<video>` src.
|
||||
*/
|
||||
const MEDIA_SRC_PATTERN = new RegExp(
|
||||
`^(https?:\\/\\/|\\/|${SUPPORT_MEDIA_PREFIX.replace("/", "\\/")})`,
|
||||
);
|
||||
const MEDIA_SRC_MESSAGE =
|
||||
`$property must be an uploaded ${SUPPORT_MEDIA_PREFIX} key, a /path, or an http(s):// URL`;
|
||||
|
||||
/* ------------------------------- CONTACT ------------------------------- */
|
||||
|
||||
export class PortalSupportContactDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(200)
|
||||
email!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(3)
|
||||
@MaxLength(60)
|
||||
phone!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(200)
|
||||
office!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(2)
|
||||
@MaxLength(200)
|
||||
hours!: string;
|
||||
}
|
||||
|
||||
/* ---------------------------- PRIVACY / TERMS --------------------------- */
|
||||
|
||||
export class PortalDocSectionDto {
|
||||
@ApiPropertyOptional({ description: "Stable id; generated when omitted" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
id?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
heading!: string;
|
||||
|
||||
@ApiProperty({ description: "Markdown" })
|
||||
@IsString()
|
||||
@MaxLength(20_000)
|
||||
body!: string;
|
||||
}
|
||||
|
||||
export class PortalLegalContentDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
title!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
subtitle!: string;
|
||||
|
||||
@ApiProperty({ description: 'Free text, e.g. "6 August 2026"' })
|
||||
@IsString()
|
||||
@MaxLength(60)
|
||||
lastUpdated!: string;
|
||||
|
||||
@ApiProperty({ type: [PortalDocSectionDto] })
|
||||
@IsArray()
|
||||
@ArrayMaxSize(60)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PortalDocSectionDto)
|
||||
sections!: PortalDocSectionDto[];
|
||||
}
|
||||
|
||||
/* --------------------------------- FAQ --------------------------------- */
|
||||
|
||||
export class PortalCtaCardDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
heading!: string;
|
||||
|
||||
@ApiProperty({ description: "Markdown" })
|
||||
@IsString()
|
||||
@MaxLength(2_000)
|
||||
body!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(100)
|
||||
ctaLabel!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@Matches(LINK_PATTERN, { message: LINK_MESSAGE })
|
||||
@MaxLength(500)
|
||||
ctaTo!: string;
|
||||
}
|
||||
|
||||
export class PortalFaqItemDto {
|
||||
@ApiPropertyOptional({ description: "Stable id; generated when omitted" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
id?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(300)
|
||||
question!: string;
|
||||
|
||||
@ApiProperty({ description: "Markdown" })
|
||||
@IsString()
|
||||
@MaxLength(5_000)
|
||||
answer!: string;
|
||||
}
|
||||
|
||||
export class PortalFaqGroupDto {
|
||||
@ApiPropertyOptional({ description: "Stable id; generated when omitted" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
id?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
title!: string;
|
||||
|
||||
@ApiProperty({ type: [PortalFaqItemDto] })
|
||||
@IsArray()
|
||||
@ArrayMaxSize(50)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PortalFaqItemDto)
|
||||
items!: PortalFaqItemDto[];
|
||||
}
|
||||
|
||||
export class PortalFaqContentDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
title!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
subtitle!: string;
|
||||
|
||||
@ApiProperty({ type: [PortalFaqGroupDto] })
|
||||
@IsArray()
|
||||
@ArrayMaxSize(20)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PortalFaqGroupDto)
|
||||
groups!: PortalFaqGroupDto[];
|
||||
|
||||
@ApiPropertyOptional({ type: PortalCtaCardDto, nullable: true })
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => PortalCtaCardDto)
|
||||
footer?: PortalCtaCardDto | null;
|
||||
}
|
||||
|
||||
/* --------------------------------- HELP -------------------------------- */
|
||||
|
||||
export class PortalMediaDto {
|
||||
@ApiPropertyOptional({ description: "Stable id; generated when omitted" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
id?: string;
|
||||
|
||||
@ApiProperty({ enum: ["image", "video"] })
|
||||
@IsIn(["image", "video"])
|
||||
kind!: "image" | "video";
|
||||
|
||||
@ApiProperty({
|
||||
description: `An uploaded ${SUPPORT_MEDIA_PREFIX} key, a same-origin /path, or an https:// URL`,
|
||||
})
|
||||
@IsString()
|
||||
@Matches(MEDIA_SRC_PATTERN, { message: MEDIA_SRC_MESSAGE })
|
||||
@MaxLength(500)
|
||||
src!: string;
|
||||
|
||||
@ApiPropertyOptional({ nullable: true })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(300)
|
||||
caption?: string | null;
|
||||
}
|
||||
|
||||
export class PortalHelpSectionDto {
|
||||
@ApiPropertyOptional({ description: "Stable id; generated when omitted" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
id?: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
heading!: string;
|
||||
|
||||
@ApiProperty({ description: "Markdown" })
|
||||
@IsString()
|
||||
@MaxLength(20_000)
|
||||
body!: string;
|
||||
|
||||
@ApiProperty({ type: [PortalMediaDto] })
|
||||
@IsArray()
|
||||
@ArrayMaxSize(12)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PortalMediaDto)
|
||||
media!: PortalMediaDto[];
|
||||
}
|
||||
|
||||
export class PortalHelpContentDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
@MaxLength(200)
|
||||
title!: string;
|
||||
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@MaxLength(500)
|
||||
subtitle!: string;
|
||||
|
||||
@ApiProperty({ type: [PortalHelpSectionDto] })
|
||||
@IsArray()
|
||||
@ArrayMaxSize(40)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => PortalHelpSectionDto)
|
||||
sections!: PortalHelpSectionDto[];
|
||||
}
|
||||
|
||||
/* ------------------------------- request ------------------------------- */
|
||||
|
||||
export class UpdateSupportDocumentDto {
|
||||
@ApiProperty({
|
||||
description:
|
||||
"The document's whole payload. Validated against the shape for its slug.",
|
||||
type: Object,
|
||||
})
|
||||
@IsObject()
|
||||
payload!: Record<string, unknown>;
|
||||
|
||||
@ApiPropertyOptional({ description: "Why this change was made" })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
note?: string;
|
||||
}
|
||||
|
||||
/** Which DTO class a slug's payload is validated against on write. */
|
||||
export const PAYLOAD_DTO_BY_SLUG: Record<
|
||||
SupportDocSlug,
|
||||
new () => object
|
||||
> = {
|
||||
CONTACT: PortalSupportContactDto,
|
||||
HELP: PortalHelpContentDto,
|
||||
FAQ: PortalFaqContentDto,
|
||||
PRIVACY: PortalLegalContentDto,
|
||||
TERMS: PortalLegalContentDto,
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import type { SupportDocPayload, SupportDocSlug } from "@edr/types";
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from "typeorm";
|
||||
|
||||
/**
|
||||
* One editable customer-facing document for the freight portal's public pages
|
||||
* (/help, /faq, /terms, /privacy) plus the support contact block they all
|
||||
* quote. Five fixed rows, keyed by slug and seeded on first boot — there is no
|
||||
* create/delete route.
|
||||
*
|
||||
* The payload is opaque jsonb because the five documents have genuinely
|
||||
* different shapes and the help page's blocks keep changing; typed columns
|
||||
* would mean a migration per copy tweak. Shape safety lives in the per-slug
|
||||
* DTOs the service validates against on write, the same arrangement
|
||||
* `contract_templates.articles` already uses.
|
||||
*
|
||||
* `version` is the live row's version and always equals `max(version)` in
|
||||
* {@link SupportDocumentVersion} — the seeder writes v1 and its history row
|
||||
* together, so the log is never missing the current state.
|
||||
*/
|
||||
@Entity({ schema: "freight", name: "support_documents" })
|
||||
@Index(["slug"], { unique: true })
|
||||
export class SupportDocument extends BaseEntity {
|
||||
@Column({ name: "slug", type: "varchar", length: 32, unique: true })
|
||||
slug!: SupportDocSlug;
|
||||
|
||||
@Column({ name: "payload", type: "jsonb", default: () => "'{}'::jsonb" })
|
||||
payload!: SupportDocPayload;
|
||||
|
||||
@Column({ name: "version", type: "int", default: 1 })
|
||||
version!: number;
|
||||
|
||||
@Column({ name: "updated_by_id", type: "uuid", nullable: true })
|
||||
updatedById?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append-only history of every saved state of a document, written in the same
|
||||
* transaction as the live row. Restoring is not a pointer reset: it re-saves an
|
||||
* old payload through the normal write path, producing a *new* version, so a
|
||||
* restore is itself undoable and the log only ever grows.
|
||||
*
|
||||
* Modelled on {@link CompanyRevision}, but snapshots the whole payload rather
|
||||
* than a field diff — rollback needs the full state, not the delta.
|
||||
*/
|
||||
@Entity({ schema: "freight", name: "support_document_versions" })
|
||||
@Index(["documentId"])
|
||||
export class SupportDocumentVersion extends BaseEntity {
|
||||
@Column({ name: "document_id", type: "uuid" })
|
||||
documentId!: string;
|
||||
|
||||
@ManyToOne(() => SupportDocument, { onDelete: "CASCADE" })
|
||||
@JoinColumn({ name: "document_id" })
|
||||
document?: SupportDocument;
|
||||
|
||||
@Column({ name: "version", type: "int" })
|
||||
version!: number;
|
||||
|
||||
@Column({ name: "payload", type: "jsonb" })
|
||||
payload!: SupportDocPayload;
|
||||
|
||||
/** Who saved it. Null for the seeded initial version. */
|
||||
@Column({ name: "actor_id", type: "uuid", nullable: true })
|
||||
actorId?: string | null;
|
||||
|
||||
/** Optional "why" the editor typed, shown in the history list. */
|
||||
@Column({ name: "note", type: "varchar", length: 255, nullable: true })
|
||||
note?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Public } from "@edr/api-common";
|
||||
import { Controller, Get, Header } from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { SupportContentService } from "./support-content.service";
|
||||
|
||||
/**
|
||||
* The portal's /help, /faq, /terms and /privacy routes are deliberately outside
|
||||
* its auth guard — the sign-up screen links to them before a session exists —
|
||||
* so this read must work with no token. `@Public()` is class-level because that
|
||||
* is the idiom the other genuinely-anonymous controllers here use; without it
|
||||
* the globally registered `JwtGuard` would 401 every anonymous visitor.
|
||||
*/
|
||||
@ApiTags("support-content")
|
||||
@Public()
|
||||
@Controller("support-content")
|
||||
export class PublicSupportContentController {
|
||||
constructor(private readonly service: SupportContentService) {}
|
||||
|
||||
@Get()
|
||||
// Hit on every anonymous page view, and the copy changes a few times a year.
|
||||
@Header("Cache-Control", "public, max-age=300")
|
||||
@ApiOperation({
|
||||
summary: "Public help, FAQ, legal and support-contact copy for the portal",
|
||||
})
|
||||
getBundle() {
|
||||
return this.service.getBundle();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import { SUPPORT_MEDIA_MAX_BYTES } from "@edr/types";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiConsumes,
|
||||
ApiOperation,
|
||||
ApiQuery,
|
||||
ApiTags,
|
||||
} from "@nestjs/swagger";
|
||||
import type { TCurrentUser } from "@tria-plc/api-common/modules/auth/types/current-user.type";
|
||||
|
||||
import { BookingStaff } from "../../common/booking-guards";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { UpdateSupportDocumentDto } from "./dto/support-content.dto";
|
||||
import { SupportContentService } from "./support-content.service";
|
||||
|
||||
const READ = [
|
||||
FREIGHT_PERMS.settings.supportContent.view,
|
||||
FREIGHT_PERMS.settings.supportContent.manage,
|
||||
FREIGHT_PERMS.admin,
|
||||
];
|
||||
|
||||
const WRITE = [
|
||||
FREIGHT_PERMS.settings.supportContent.manage,
|
||||
FREIGHT_PERMS.admin,
|
||||
];
|
||||
|
||||
@ApiTags("support-content")
|
||||
@ApiBearerAuth()
|
||||
@Controller("support-content")
|
||||
export class SupportContentController {
|
||||
constructor(private readonly service: SupportContentService) {}
|
||||
|
||||
/**
|
||||
* Stores a help-page image or video and returns its object key. The editor
|
||||
* saves the key, not the returned URL — see `SupportContentService.uploadMedia`.
|
||||
*
|
||||
* The Multer limit duplicates the service-side type check on purpose: it
|
||||
* stops reading the socket once the part is oversized instead of buffering
|
||||
* the whole thing into memory first.
|
||||
*/
|
||||
@Post("media")
|
||||
@BookingStaff(WRITE)
|
||||
@UseInterceptors(
|
||||
FileInterceptor("file", { limits: { fileSize: SUPPORT_MEDIA_MAX_BYTES } }),
|
||||
)
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({ summary: "Upload an image or video for a help section" })
|
||||
uploadMedia(@UploadedFile() file: Express.Multer.File) {
|
||||
return this.service.uploadMedia(file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Signs one stored key so the editor can preview an already-saved image.
|
||||
* The editor stores `minio:<key>`, never a signed URL, so it needs somewhere
|
||||
* to resolve those refs for display — this is it.
|
||||
*/
|
||||
@Get("media-url")
|
||||
@BookingStaff(READ)
|
||||
@ApiQuery({ name: "key", description: "MinIO object key" })
|
||||
@ApiOperation({ summary: "Presigned URL for one stored media key" })
|
||||
mediaUrl(@Query("key") key: string) {
|
||||
return this.service.mediaUrl(key);
|
||||
}
|
||||
|
||||
@Get("documents")
|
||||
@BookingStaff(READ)
|
||||
@ApiOperation({ summary: "List the five portal content documents (no payloads)" })
|
||||
list() {
|
||||
return this.service.list();
|
||||
}
|
||||
|
||||
@Get("documents/:slug")
|
||||
@BookingStaff(READ)
|
||||
@ApiOperation({ summary: "Get one document with its payload" })
|
||||
getBySlug(@Param("slug") slug: string) {
|
||||
return this.service.getBySlug(slug);
|
||||
}
|
||||
|
||||
@Patch("documents/:slug")
|
||||
@BookingStaff(WRITE)
|
||||
@ApiOperation({
|
||||
summary: "Replace a document's payload, recording a new version",
|
||||
})
|
||||
update(
|
||||
@Param("slug") slug: string,
|
||||
@Body() dto: UpdateSupportDocumentDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.service.update(slug, dto, user?.id ?? null);
|
||||
}
|
||||
|
||||
@Get("documents/:slug/versions")
|
||||
@BookingStaff(READ)
|
||||
@ApiOperation({ summary: "Version history, newest first (no payloads)" })
|
||||
listVersions(@Param("slug") slug: string) {
|
||||
return this.service.listVersions(slug);
|
||||
}
|
||||
|
||||
@Get("documents/:slug/versions/:version")
|
||||
@BookingStaff(READ)
|
||||
@ApiOperation({ summary: "One historical version, with its payload" })
|
||||
getVersion(
|
||||
@Param("slug") slug: string,
|
||||
@Param("version", ParseIntPipe) version: number,
|
||||
) {
|
||||
return this.service.getVersion(slug, version);
|
||||
}
|
||||
|
||||
@Post("documents/:slug/versions/:version/restore")
|
||||
@BookingStaff(WRITE)
|
||||
@ApiOperation({
|
||||
summary: "Restore a version — re-saves it as a new version, never destructive",
|
||||
})
|
||||
restore(
|
||||
@Param("slug") slug: string,
|
||||
@Param("version", ParseIntPipe) version: number,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.service.restore(slug, version, user?.id ?? null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { MinioModule } from "../minio/minio.module";
|
||||
import {
|
||||
SupportDocument,
|
||||
SupportDocumentVersion,
|
||||
} from "./entities/support-document.entity";
|
||||
import { PublicSupportContentController } from "./public-support-content.controller";
|
||||
import { SupportContentController } from "./support-content.controller";
|
||||
import { SupportContentRepository } from "./support-content.repository";
|
||||
import { SupportContentService } from "./support-content.service";
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([SupportDocument, SupportDocumentVersion]),
|
||||
MinioModule,
|
||||
],
|
||||
controllers: [PublicSupportContentController, SupportContentController],
|
||||
providers: [SupportContentRepository, SupportContentService],
|
||||
exports: [SupportContentService],
|
||||
})
|
||||
export class SupportContentModule {}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import type { SupportDocSlug } from "@edr/types";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import {
|
||||
SupportDocument,
|
||||
SupportDocumentVersion,
|
||||
} from "./entities/support-document.entity";
|
||||
|
||||
@Injectable()
|
||||
export class SupportContentRepository extends BaseRepository<SupportDocument> {
|
||||
constructor(
|
||||
@InjectRepository(SupportDocument)
|
||||
repository: Repository<SupportDocument>,
|
||||
@InjectRepository(SupportDocumentVersion)
|
||||
private readonly versions: Repository<SupportDocumentVersion>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
findBySlug(slug: SupportDocSlug): Promise<SupportDocument | null> {
|
||||
return this.repository.findOne({ where: { slug } });
|
||||
}
|
||||
|
||||
override findAll(): Promise<SupportDocument[]> {
|
||||
return this.repository.find({ order: { slug: "ASC" } });
|
||||
}
|
||||
|
||||
findVersions(documentId: string): Promise<SupportDocumentVersion[]> {
|
||||
return this.versions.find({
|
||||
where: { documentId },
|
||||
order: { version: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
findVersion(
|
||||
documentId: string,
|
||||
version: number,
|
||||
): Promise<SupportDocumentVersion | null> {
|
||||
return this.versions.findOne({ where: { documentId, version } });
|
||||
}
|
||||
|
||||
/**
|
||||
* Bump the live row and append its history entry atomically. Doing both in
|
||||
* one transaction is what guarantees `document.version` always has a matching
|
||||
* version row — the invariant the history list and rollback both rely on.
|
||||
*/
|
||||
async saveWithVersion(
|
||||
document: SupportDocument,
|
||||
actorId: string | null,
|
||||
note: string | null,
|
||||
): Promise<SupportDocument> {
|
||||
return this.repository.manager.transaction(async (manager) => {
|
||||
const saved = await manager.save(SupportDocument, document);
|
||||
|
||||
await manager.save(
|
||||
manager.create(SupportDocumentVersion, {
|
||||
documentId: saved.id,
|
||||
version: saved.version,
|
||||
payload: saved.payload,
|
||||
actorId,
|
||||
note,
|
||||
}),
|
||||
);
|
||||
|
||||
return saved;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import {
|
||||
SUPPORT_CONTENT_DEFAULTS,
|
||||
SUPPORT_DOC_SLUGS,
|
||||
SupportDocPayload,
|
||||
SupportDocSlug,
|
||||
} from "@edr/types";
|
||||
import { BadRequestException, NotFoundException } from "@nestjs/common";
|
||||
|
||||
import { MinioService } from "../minio/minio.service";
|
||||
import {
|
||||
SupportDocument,
|
||||
SupportDocumentVersion,
|
||||
} from "./entities/support-document.entity";
|
||||
import { SupportContentRepository } from "./support-content.repository";
|
||||
import { SupportContentService, validatePayload } from "./support-content.service";
|
||||
|
||||
const ACTOR = "11111111-1111-4111-8111-111111111111";
|
||||
|
||||
/**
|
||||
* In-memory stand-in for the repository: one seeded document plus its version
|
||||
* log, with `saveWithVersion` doing what the real transaction does. Enough to
|
||||
* assert the rollback contract without a database.
|
||||
*/
|
||||
function makeRepository(slug: SupportDocSlug) {
|
||||
const document = {
|
||||
id: "00000000-0000-0000-0000-000000000001",
|
||||
slug,
|
||||
payload: SUPPORT_CONTENT_DEFAULTS[slug],
|
||||
version: 1,
|
||||
updatedById: null,
|
||||
} as SupportDocument;
|
||||
|
||||
const versions: SupportDocumentVersion[] = [
|
||||
{
|
||||
id: "v1",
|
||||
documentId: document.id,
|
||||
version: 1,
|
||||
payload: document.payload,
|
||||
actorId: null,
|
||||
note: "Initial content",
|
||||
} as SupportDocumentVersion,
|
||||
];
|
||||
|
||||
const repository = {
|
||||
findAll: jest.fn(async () => [document]),
|
||||
findBySlug: jest.fn(async (s: SupportDocSlug) =>
|
||||
s === slug ? document : null,
|
||||
),
|
||||
findVersions: jest.fn(async () => [...versions].reverse()),
|
||||
findVersion: jest.fn(
|
||||
async (_id: string, version: number) =>
|
||||
versions.find((v) => v.version === version) ?? null,
|
||||
),
|
||||
saveWithVersion: jest.fn(
|
||||
async (doc: SupportDocument, actorId: string | null, note: string | null) => {
|
||||
versions.push({
|
||||
id: `v${doc.version}`,
|
||||
documentId: doc.id,
|
||||
version: doc.version,
|
||||
payload: doc.payload,
|
||||
actorId,
|
||||
note,
|
||||
} as SupportDocumentVersion);
|
||||
return doc;
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
// Signing is exercised only through getBundle; the write path never touches it.
|
||||
const minio = {
|
||||
getSignedUrl: jest.fn(async (key: string) => `https://minio.test/${key}?sig=x`),
|
||||
uploadFile: jest.fn(),
|
||||
};
|
||||
|
||||
return {
|
||||
document,
|
||||
versions,
|
||||
minio,
|
||||
service: new SupportContentService(
|
||||
repository as unknown as SupportContentRepository,
|
||||
minio as unknown as MinioService,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
describe("SupportContentService.restore", () => {
|
||||
it("rolls back to an older payload as a NEW version, leaving history intact", async () => {
|
||||
const { document, versions, service } = makeRepository("CONTACT");
|
||||
const original = SUPPORT_CONTENT_DEFAULTS.CONTACT;
|
||||
|
||||
await service.update(
|
||||
"CONTACT",
|
||||
{ payload: { ...original, phone: "+251 99 999 9999" } },
|
||||
ACTOR,
|
||||
);
|
||||
|
||||
expect(document.version).toBe(2);
|
||||
expect(versions.map((v) => v.version)).toEqual([1, 2]);
|
||||
|
||||
const restored = await service.restore("CONTACT", 1, ACTOR);
|
||||
|
||||
expect(restored.payload).toEqual(original);
|
||||
// The assertion that matters: restore is additive. If someone "optimises"
|
||||
// it into a destructive pointer reset this drops back to 1 and rollback
|
||||
// silently stops being undoable.
|
||||
expect(restored.version).toBe(3);
|
||||
expect(versions.map((v) => v.version)).toEqual([1, 2, 3]);
|
||||
expect(versions[2].note).toBe("Restored version 1");
|
||||
});
|
||||
|
||||
it("404s on a version that was never written", async () => {
|
||||
const { service } = makeRepository("CONTACT");
|
||||
await expect(service.restore("CONTACT", 99, ACTOR)).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an unknown slug", async () => {
|
||||
const { service } = makeRepository("CONTACT");
|
||||
await expect(service.getBySlug("NOPE")).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SupportContentService.getBundle", () => {
|
||||
it("signs stored keys on read and leaves paths and URLs alone", async () => {
|
||||
const { service, minio } = makeRepository("CONTACT");
|
||||
const help = SUPPORT_CONTENT_DEFAULTS.HELP;
|
||||
|
||||
// findAll returns only CONTACT, so HELP falls back to the defaults — which
|
||||
// is itself worth asserting: an unseeded row must not break the page.
|
||||
const bundle = await service.getBundle();
|
||||
expect(bundle.help.sections[0].media[0].src).toBe(
|
||||
"/assets/edr-portal-guide.webm",
|
||||
);
|
||||
expect(minio.getSignedUrl).not.toHaveBeenCalled();
|
||||
|
||||
const withUpload = {
|
||||
...help,
|
||||
sections: [
|
||||
{
|
||||
...help.sections[0],
|
||||
body: "See  below.",
|
||||
media: [
|
||||
{ id: "m1", kind: "image" as const, src: "support-content/a.png" },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const signed = await (service as any).signMedia({
|
||||
...bundle,
|
||||
help: withUpload,
|
||||
});
|
||||
|
||||
expect(signed.help.sections[0].media[0].src).toBe(
|
||||
"https://minio.test/support-content/a.png?sig=x",
|
||||
);
|
||||
expect(signed.help.sections[0].body).toContain(
|
||||
"https://minio.test/support-content/d.png?sig=x",
|
||||
);
|
||||
// The stored copy must never be mutated into a URL — that is what would rot.
|
||||
expect(withUpload.sections[0].media[0].src).toBe("support-content/a.png");
|
||||
});
|
||||
});
|
||||
|
||||
describe("validatePayload", () => {
|
||||
const help = SUPPORT_CONTENT_DEFAULTS.HELP;
|
||||
|
||||
it("accepts every shipped default", () => {
|
||||
for (const slug of SUPPORT_DOC_SLUGS) {
|
||||
expect(() =>
|
||||
validatePayload(slug, SUPPORT_CONTENT_DEFAULTS[slug]),
|
||||
).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
const sectionWithMedia = (src: string) => ({
|
||||
...help,
|
||||
sections: [{ ...help.sections[0], media: [{ kind: "video", src }] }],
|
||||
});
|
||||
|
||||
it("rejects a javascript: media source", () => {
|
||||
// The markdown renderer drops raw HTML, so src attributes like this one are
|
||||
// the only place a script URL could still execute.
|
||||
expect(() =>
|
||||
validatePayload("HELP", sectionWithMedia("javascript:alert(1)")),
|
||||
).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it("accepts an uploaded key, a rooted path and an https URL", () => {
|
||||
for (const src of [
|
||||
"support-content/9f1c.png",
|
||||
"/assets/edr-portal-guide.webm",
|
||||
"https://cdn.example.com/clip.mp4",
|
||||
]) {
|
||||
expect(() => validatePayload("HELP", sectionWithMedia(src))).not.toThrow();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a media kind that is neither image nor video", () => {
|
||||
expect(() =>
|
||||
validatePayload("HELP", {
|
||||
...help,
|
||||
sections: [
|
||||
{
|
||||
...help.sections[0],
|
||||
media: [{ kind: "pdf", src: "support-content/a.pdf" }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it("keeps placeholder links, which sit after the scheme", () => {
|
||||
expect(() =>
|
||||
validatePayload("FAQ", {
|
||||
...SUPPORT_CONTENT_DEFAULTS.FAQ,
|
||||
footer: {
|
||||
...SUPPORT_CONTENT_DEFAULTS.FAQ.footer!,
|
||||
ctaTo: "mailto:{{supportEmail}}",
|
||||
},
|
||||
}),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("generates ids for items saved without one", () => {
|
||||
const legal = validatePayload("TERMS", {
|
||||
...SUPPORT_CONTENT_DEFAULTS.TERMS,
|
||||
sections: [{ heading: "1. New", body: "Body." }],
|
||||
}) as Extract<SupportDocPayload, { sections: unknown }>;
|
||||
|
||||
expect(legal.sections[0].id).toEqual(expect.any(String));
|
||||
expect(legal.sections[0].id.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,368 @@
|
||||
import {
|
||||
PORTAL_MEDIA_URI_SCHEME,
|
||||
PortalContentBundle,
|
||||
PortalFaqContent,
|
||||
PortalHelpContent,
|
||||
PortalLegalContent,
|
||||
PortalMediaKind,
|
||||
SUPPORT_CONTENT_DEFAULTS,
|
||||
SUPPORT_DOC_SLUGS,
|
||||
SUPPORT_MEDIA_PREFIX,
|
||||
SupportDocPayload,
|
||||
SupportDocSlug,
|
||||
} from "@edr/types";
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { extname } from "path";
|
||||
|
||||
import { MinioService } from "../minio/minio.service";
|
||||
import { plainToInstance } from "class-transformer";
|
||||
import { validateSync, ValidationError } from "class-validator";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
import {
|
||||
PAYLOAD_DTO_BY_SLUG,
|
||||
UpdateSupportDocumentDto,
|
||||
} from "./dto/support-content.dto";
|
||||
import {
|
||||
SupportDocument,
|
||||
SupportDocumentVersion,
|
||||
} from "./entities/support-document.entity";
|
||||
import { SupportContentRepository } from "./support-content.repository";
|
||||
|
||||
/**
|
||||
* A paste-bomb in one section would bloat the public bundle every anonymous
|
||||
* visitor downloads, so the whole payload is capped as well as its fields.
|
||||
*/
|
||||
const MAX_PAYLOAD_BYTES = 200_000;
|
||||
|
||||
/**
|
||||
* Comfortably longer than the 5-minute `Cache-Control` on the public bundle, so
|
||||
* a cached response never outlives the URLs inside it.
|
||||
*/
|
||||
const MEDIA_URL_TTL_SECONDS = 6 * 60 * 60;
|
||||
|
||||
/** `minio:support-content/<file>` inside markdown. */
|
||||
const MEDIA_REF = new RegExp(
|
||||
`${PORTAL_MEDIA_URI_SCHEME}([A-Za-z0-9._\\-/]+)`,
|
||||
"g",
|
||||
);
|
||||
|
||||
/** Anything not already a URL or a rooted path is a MinIO object key. */
|
||||
const isObjectKey = (src: string) => !/^(https?:\/\/|\/)/.test(src);
|
||||
|
||||
@Injectable()
|
||||
export class SupportContentService {
|
||||
constructor(
|
||||
private readonly repository: SupportContentRepository,
|
||||
private readonly minio: MinioService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Stores an image or video for the help page and returns its object key.
|
||||
*
|
||||
* The key is what gets saved in the document — never the signed URL. A
|
||||
* presigned URL expires, so persisting one would leave every embedded image
|
||||
* broken a few hours later; signing happens per read instead.
|
||||
*/
|
||||
async uploadMedia(
|
||||
file?: Express.Multer.File,
|
||||
): Promise<{ key: string; kind: PortalMediaKind; url: string }> {
|
||||
if (!file) throw new BadRequestException("No file uploaded");
|
||||
|
||||
const kind: PortalMediaKind | null = file.mimetype.startsWith("image/")
|
||||
? "image"
|
||||
: file.mimetype.startsWith("video/")
|
||||
? "video"
|
||||
: null;
|
||||
|
||||
if (!kind) {
|
||||
throw new BadRequestException(
|
||||
`Unsupported file type ${file.mimetype} — images and videos only`,
|
||||
);
|
||||
}
|
||||
|
||||
const key = `${SUPPORT_MEDIA_PREFIX}${randomUUID()}${extname(file.originalname).toLowerCase()}`;
|
||||
await this.minio.uploadFile(key, file.buffer, file.mimetype);
|
||||
|
||||
return {
|
||||
key,
|
||||
kind,
|
||||
url: await this.minio.getSignedUrl(key, MEDIA_URL_TTL_SECONDS),
|
||||
};
|
||||
}
|
||||
|
||||
/** Presigned URL for one stored key, for the backoffice editor's previews. */
|
||||
async mediaUrl(key: string): Promise<{ url: string }> {
|
||||
if (!key?.startsWith(SUPPORT_MEDIA_PREFIX)) {
|
||||
throw new BadRequestException(
|
||||
`key must be an uploaded ${SUPPORT_MEDIA_PREFIX} object`,
|
||||
);
|
||||
}
|
||||
return { url: await this.minio.getSignedUrl(key, MEDIA_URL_TTL_SECONDS) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The public bundle. Missing rows fall back to the shipped defaults so an
|
||||
* unseeded or half-migrated environment still serves the legal pages rather
|
||||
* than 404-ing the first thing an anonymous visitor sees.
|
||||
*/
|
||||
async getBundle(): Promise<PortalContentBundle> {
|
||||
const documents = await this.repository.findAll();
|
||||
const bySlug = new Map(documents.map((d) => [d.slug, d.payload]));
|
||||
|
||||
const payload = <S extends SupportDocSlug>(slug: S) =>
|
||||
(bySlug.get(slug) ?? SUPPORT_CONTENT_DEFAULTS[slug]) as never;
|
||||
|
||||
return this.signMedia({
|
||||
contact: payload("CONTACT"),
|
||||
help: payload("HELP"),
|
||||
faq: payload("FAQ"),
|
||||
privacy: payload("PRIVACY"),
|
||||
terms: payload("TERMS"),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Swaps every stored MinIO reference for a freshly signed URL: attachment
|
||||
* `src` keys, and `minio:<key>` references embedded in markdown by the
|
||||
* editor's image button.
|
||||
*
|
||||
* Each distinct key is signed once per request, and a signing failure
|
||||
* degrades to MinIO's public URL rather than failing the whole page (see
|
||||
* `MinioService.getSignedUrl`).
|
||||
*/
|
||||
private async signMedia(
|
||||
bundle: PortalContentBundle,
|
||||
): Promise<PortalContentBundle> {
|
||||
const keys = new Set<string>();
|
||||
|
||||
for (const section of bundle.help.sections ?? []) {
|
||||
for (const item of section.media ?? []) {
|
||||
if (isObjectKey(item.src)) keys.add(item.src);
|
||||
}
|
||||
}
|
||||
|
||||
const collect = (value: unknown): void => {
|
||||
if (typeof value === "string") {
|
||||
for (const match of value.matchAll(MEDIA_REF)) keys.add(match[1]);
|
||||
} else if (Array.isArray(value)) {
|
||||
value.forEach(collect);
|
||||
} else if (value && typeof value === "object") {
|
||||
Object.values(value).forEach(collect);
|
||||
}
|
||||
};
|
||||
collect(bundle);
|
||||
|
||||
if (keys.size === 0) return bundle;
|
||||
|
||||
const signed = new Map(
|
||||
await Promise.all(
|
||||
[...keys].map(
|
||||
async (key) =>
|
||||
[key, await this.minio.getSignedUrl(key, MEDIA_URL_TTL_SECONDS)] as const,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const rewrite = (value: unknown): unknown => {
|
||||
if (typeof value === "string") {
|
||||
return value.replace(MEDIA_REF, (whole, key: string) =>
|
||||
signed.get(key) ?? whole,
|
||||
);
|
||||
}
|
||||
if (Array.isArray(value)) return value.map(rewrite);
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([k, v]) => [k, rewrite(v)]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const resolved = rewrite(bundle) as PortalContentBundle;
|
||||
|
||||
for (const section of resolved.help.sections ?? []) {
|
||||
for (const item of section.media ?? []) {
|
||||
if (isObjectKey(item.src)) item.src = signed.get(item.src) ?? item.src;
|
||||
}
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/** Admin list — metadata only, no payloads. */
|
||||
async list(): Promise<Omit<SupportDocument, "payload">[]> {
|
||||
const documents = await this.repository.findAll();
|
||||
return documents.map(({ payload: _payload, ...rest }) => rest);
|
||||
}
|
||||
|
||||
async getBySlug(rawSlug: string): Promise<SupportDocument> {
|
||||
const slug = assertSlug(rawSlug);
|
||||
const document = await this.repository.findBySlug(slug);
|
||||
if (!document) {
|
||||
throw new NotFoundException(`Support document ${slug} not found`);
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a document's whole payload. Writing the whole slice rather than
|
||||
* patching fields is deliberate: one editorial change becomes exactly one
|
||||
* version, which is what keeps the history readable.
|
||||
*/
|
||||
async update(
|
||||
rawSlug: string,
|
||||
dto: UpdateSupportDocumentDto,
|
||||
actorId: string | null,
|
||||
): Promise<SupportDocument> {
|
||||
const document = await this.getBySlug(rawSlug);
|
||||
const payload = validatePayload(document.slug, dto.payload);
|
||||
|
||||
document.payload = payload;
|
||||
document.version += 1;
|
||||
document.updatedById = actorId;
|
||||
|
||||
return this.repository.saveWithVersion(document, actorId, dto.note ?? null);
|
||||
}
|
||||
|
||||
async listVersions(rawSlug: string): Promise<SupportDocumentVersion[]> {
|
||||
const document = await this.getBySlug(rawSlug);
|
||||
return this.repository.findVersions(document.id);
|
||||
}
|
||||
|
||||
async getVersion(
|
||||
rawSlug: string,
|
||||
version: number,
|
||||
): Promise<SupportDocumentVersion> {
|
||||
const document = await this.getBySlug(rawSlug);
|
||||
const found = await this.repository.findVersion(document.id, version);
|
||||
if (!found) {
|
||||
throw new NotFoundException(
|
||||
`Version ${version} of ${document.slug} not found`,
|
||||
);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* Roll back to an earlier version by re-saving its payload through the normal
|
||||
* write path. The result is a NEW version whose content equals the old one —
|
||||
* never a destructive pointer reset — so the history only grows and a restore
|
||||
* is itself undoable.
|
||||
*/
|
||||
async restore(
|
||||
rawSlug: string,
|
||||
version: number,
|
||||
actorId: string | null,
|
||||
): Promise<SupportDocument> {
|
||||
const target = await this.getVersion(rawSlug, version);
|
||||
|
||||
return this.update(
|
||||
rawSlug,
|
||||
{
|
||||
payload: target.payload as unknown as Record<string, unknown>,
|
||||
note: `Restored version ${version}`,
|
||||
},
|
||||
actorId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------ helpers ------------------------------- */
|
||||
|
||||
export function assertSlug(raw: string): SupportDocSlug {
|
||||
const slug = raw?.toUpperCase() as SupportDocSlug;
|
||||
if (!SUPPORT_DOC_SLUGS.includes(slug)) {
|
||||
throw new BadRequestException(
|
||||
`Unknown support document "${raw}". Valid slugs: ${SUPPORT_DOC_SLUGS.join(", ")}`,
|
||||
);
|
||||
}
|
||||
return slug;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate an incoming payload against the DTO registered for its slug, then
|
||||
* fill in any missing item ids. Exported so the seeder's spec can assert the
|
||||
* shipped defaults are themselves valid.
|
||||
*/
|
||||
export function validatePayload(
|
||||
slug: SupportDocSlug,
|
||||
raw: unknown,
|
||||
): SupportDocPayload {
|
||||
if (JSON.stringify(raw ?? null).length > MAX_PAYLOAD_BYTES) {
|
||||
throw new BadRequestException(
|
||||
`Payload for ${slug} exceeds ${MAX_PAYLOAD_BYTES} bytes`,
|
||||
);
|
||||
}
|
||||
|
||||
const instance = plainToInstance(PAYLOAD_DTO_BY_SLUG[slug], raw ?? {});
|
||||
const errors = validateSync(instance as object, {
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
});
|
||||
|
||||
if (errors.length) {
|
||||
throw new BadRequestException(flattenErrors(errors));
|
||||
}
|
||||
|
||||
return withGeneratedIds(slug, instance as SupportDocPayload);
|
||||
}
|
||||
|
||||
function flattenErrors(errors: ValidationError[], path = ""): string[] {
|
||||
return errors.flatMap((error) => {
|
||||
const here = path ? `${path}.${error.property}` : error.property;
|
||||
const own = Object.values(error.constraints ?? {}).map(
|
||||
(message) => `${here}: ${message}`,
|
||||
);
|
||||
return [...own, ...flattenErrors(error.children ?? [], here)];
|
||||
});
|
||||
}
|
||||
|
||||
const withId = <T extends { id?: string }>(item: T): T => ({
|
||||
...item,
|
||||
id: item.id || randomUUID(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Item ids are the React keys in the portal, and admin-authored headings and
|
||||
* questions collide too easily to use as keys. Editors may omit them; the
|
||||
* server mints one rather than making every client remember to.
|
||||
*/
|
||||
function withGeneratedIds(
|
||||
slug: SupportDocSlug,
|
||||
payload: SupportDocPayload,
|
||||
): SupportDocPayload {
|
||||
switch (slug) {
|
||||
case "HELP": {
|
||||
const help = payload as PortalHelpContent;
|
||||
return {
|
||||
...help,
|
||||
sections: help.sections.map((section) => ({
|
||||
...withId(section),
|
||||
media: (section.media ?? []).map(withId),
|
||||
})),
|
||||
};
|
||||
}
|
||||
case "FAQ": {
|
||||
const faq = payload as PortalFaqContent;
|
||||
return {
|
||||
...faq,
|
||||
groups: faq.groups.map((group) => ({
|
||||
...withId(group),
|
||||
items: group.items.map(withId),
|
||||
})),
|
||||
};
|
||||
}
|
||||
case "PRIVACY":
|
||||
case "TERMS": {
|
||||
const legal = payload as PortalLegalContent;
|
||||
return { ...legal, sections: legal.sections.map(withId) };
|
||||
}
|
||||
default:
|
||||
return payload;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user