feat: WIP Contnet managemtn

This commit is contained in:
Nathnael
2026-08-08 09:37:28 +00:00
parent ee25de8817
commit 41e8c08ba3
48 changed files with 6601 additions and 657 deletions

View File

@@ -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,
};

View File

@@ -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;
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -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;
});
}
}

View File

@@ -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 ![diagram](minio:support-content/d.png) 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);
});
});

View File

@@ -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;
}
}