mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-06 08:53:39 +00:00
feat: add publication
This commit is contained in:
@@ -58,6 +58,7 @@ import { StampSettingsModule } from "./modules/stamp-settings/stamp-settings.mod
|
||||
import { LogoSettingsModule } from "./modules/logo-settings/logo-settings.module";
|
||||
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
|
||||
import { SupportContentModule } from "./modules/support-content/support-content.module";
|
||||
import { PublicationsModule } from "./modules/publications/publications.module";
|
||||
import { OtpModule } from "./modules/otp/otp.module";
|
||||
import { HealthModule } from "./modules/health/health.module";
|
||||
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
|
||||
@@ -231,6 +232,7 @@ if (!process.env.APPLICATION_NAME) {
|
||||
LogoSettingsModule,
|
||||
ContractTemplatesModule,
|
||||
SupportContentModule,
|
||||
PublicationsModule,
|
||||
OtpModule,
|
||||
HealthModule,
|
||||
RuleEngineModule,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Public document library for the freight portal (PDFs, Markdown write-ups,
|
||||
* PowerPoint decks about the platform), managed from the backoffice. Each row
|
||||
* is one whole file stored in MinIO under `publications/` — a re-upload
|
||||
* replaces the object and the row's file columns, there is no per-version
|
||||
* history table like `support_documents` has.
|
||||
*/
|
||||
export class Publications3850000000000 implements MigrationInterface {
|
||||
name = 'Publications3850000000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS freight.publications (
|
||||
id uuid PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||
title varchar(200) NOT NULL,
|
||||
description text,
|
||||
category varchar(60),
|
||||
file_key varchar(512) NOT NULL,
|
||||
file_name varchar(255) NOT NULL,
|
||||
file_mime_type varchar(120) NOT NULL,
|
||||
file_size_bytes bigint NOT NULL,
|
||||
sort_order integer NOT NULL DEFAULT 0,
|
||||
published boolean NOT NULL DEFAULT true,
|
||||
published_at timestamptz,
|
||||
uploaded_by_id uuid,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
deleted_at timestamptz
|
||||
)
|
||||
`);
|
||||
|
||||
// Serves the public list: published rows in display order.
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS idx_publications_published_sort
|
||||
ON freight.publications (published, sort_order)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS freight.publications`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Transform } from "class-transformer";
|
||||
import { IsBoolean, IsInt, IsOptional, IsString, MaxLength } from "class-validator";
|
||||
|
||||
/**
|
||||
* Metadata fields for `POST /publications`, sent alongside the file as
|
||||
* multipart/form-data — every field arrives as a string, so numeric/boolean
|
||||
* fields need an explicit `@Transform` (global `enableImplicitConversion` is
|
||||
* off, see main.ts).
|
||||
*/
|
||||
export class CreatePublicationDto {
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
title!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(60)
|
||||
category?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Transform(({ value }) => Number(value ?? 0))
|
||||
sortOrder?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Transform(({ value }) => value === undefined || value === "true" || value === true)
|
||||
published?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PartialType } from "@nestjs/mapped-types";
|
||||
|
||||
import { CreatePublicationDto } from "./create-publication.dto";
|
||||
|
||||
export class UpdatePublicationDto extends PartialType(CreatePublicationDto) {}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import { Column, Entity, Index } from "typeorm";
|
||||
|
||||
/**
|
||||
* One document in the freight portal's public library (/publications) — a
|
||||
* PDF, Markdown write-up, or PowerPoint deck about the platform, uploaded and
|
||||
* curated from the backoffice. Unlike `SupportDocument`'s five fixed slugs
|
||||
* edited in place, this is a real table of many rows and each upload is a
|
||||
* whole new file — there is no version-history log here, a re-upload just
|
||||
* replaces the file columns (see `PublicationsService.replaceFile`).
|
||||
*/
|
||||
@Entity({ schema: "freight", name: "publications" })
|
||||
@Index(["published", "sortOrder"])
|
||||
export class Publication extends BaseEntity {
|
||||
@Column({ name: "title", type: "varchar", length: 200 })
|
||||
title!: string;
|
||||
|
||||
@Column({ name: "description", type: "text", nullable: true })
|
||||
description?: string | null;
|
||||
|
||||
@Column({ name: "category", type: "varchar", length: 60, nullable: true })
|
||||
category?: string | null;
|
||||
|
||||
/** MinIO object key. Never a signed URL — those expire; sign on read instead. */
|
||||
@Column({ name: "file_key", type: "varchar", length: 512 })
|
||||
fileKey!: string;
|
||||
|
||||
/** Original filename, used for the download's Content-Disposition. */
|
||||
@Column({ name: "file_name", type: "varchar", length: 255 })
|
||||
fileName!: string;
|
||||
|
||||
@Column({ name: "file_mime_type", type: "varchar", length: 120 })
|
||||
fileMimeType!: string;
|
||||
|
||||
@Column({ name: "file_size_bytes", type: "bigint" })
|
||||
fileSizeBytes!: number;
|
||||
|
||||
/** Manual ordering in the backoffice list and the public grid. */
|
||||
@Column({ name: "sort_order", type: "integer", default: 0 })
|
||||
sortOrder!: number;
|
||||
|
||||
/** Unpublish without deleting — hides it from the public list only. */
|
||||
@Column({ name: "published", type: "boolean", default: true })
|
||||
published!: boolean;
|
||||
|
||||
@Column({ name: "published_at", type: "timestamptz", nullable: true })
|
||||
publishedAt?: Date | null;
|
||||
|
||||
@Column({ name: "uploaded_by_id", type: "uuid", nullable: true })
|
||||
uploadedById?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Public } from "@edr/api-common";
|
||||
import { Controller, Get, Header, Param, ParseUUIDPipe, Query, Res } from "@nestjs/common";
|
||||
import { Response } from "express";
|
||||
import { ApiOperation, ApiQuery, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { PublicationsService } from "./publications.service";
|
||||
|
||||
/**
|
||||
* The portal's /publications page — a public library of PDFs, Markdown
|
||||
* write-ups and PowerPoint decks about the platform. No login required, same
|
||||
* as /help, /faq and the legal pages: prospects reach it before any account
|
||||
* exists.
|
||||
*/
|
||||
@ApiTags("publications")
|
||||
@Public()
|
||||
@Controller("publications")
|
||||
export class PublicPublicationsController {
|
||||
constructor(private readonly service: PublicationsService) {}
|
||||
|
||||
@Get()
|
||||
// Cheap to serve stale for a few minutes; every anonymous page view hits it.
|
||||
@Header("Cache-Control", "public, max-age=300")
|
||||
@ApiOperation({ summary: "List published publications for the public library" })
|
||||
list() {
|
||||
return this.service.listPublic();
|
||||
}
|
||||
|
||||
@Get(":id/file")
|
||||
@ApiQuery({
|
||||
name: "download",
|
||||
required: false,
|
||||
description: "Set to 1/true to force a download instead of inline preview.",
|
||||
})
|
||||
@ApiOperation({ summary: "Stream a published publication's file" })
|
||||
async getFile(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Query("download") download: string | undefined,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { stream, record } = await this.service.getPublishedFileStream(id);
|
||||
const forceDownload = download === "1" || download === "true";
|
||||
|
||||
res.setHeader("Content-Type", record.fileMimeType);
|
||||
res.setHeader(
|
||||
"Content-Disposition",
|
||||
`${forceDownload ? "attachment" : "inline"}; filename="${record.fileName}"`,
|
||||
);
|
||||
res.setHeader("Cache-Control", "public, max-age=300");
|
||||
stream.pipe(res);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { CurrentUser } from "@edr/api-common";
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from "@nestjs/common";
|
||||
import { FileInterceptor } from "@nestjs/platform-express";
|
||||
import { ApiBearerAuth, ApiConsumes, ApiOperation, 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 { documentUploadMulterOptions } from "../../common/document-upload.options";
|
||||
import { FREIGHT_PERMS } from "../../seed/freight-permissions.registry";
|
||||
import { CreatePublicationDto } from "./dto/create-publication.dto";
|
||||
import { UpdatePublicationDto } from "./dto/update-publication.dto";
|
||||
import { PublicationsService } from "./publications.service";
|
||||
|
||||
const READ = [FREIGHT_PERMS.settings.publications.view, FREIGHT_PERMS.settings.publications.manage, FREIGHT_PERMS.admin];
|
||||
const WRITE = [FREIGHT_PERMS.settings.publications.manage, FREIGHT_PERMS.admin];
|
||||
|
||||
@ApiTags("publications")
|
||||
@ApiBearerAuth()
|
||||
@Controller("publications")
|
||||
export class PublicationsController {
|
||||
constructor(private readonly service: PublicationsService) {}
|
||||
|
||||
@Get("admin")
|
||||
@BookingStaff(READ)
|
||||
@ApiOperation({ summary: "List every publication, published or not" })
|
||||
list() {
|
||||
return this.service.list();
|
||||
}
|
||||
|
||||
@Post()
|
||||
@BookingStaff(WRITE)
|
||||
@UseInterceptors(FileInterceptor("file", documentUploadMulterOptions))
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({ summary: "Upload a new publication" })
|
||||
create(
|
||||
@UploadedFile() file: Express.Multer.File,
|
||||
@Body() dto: CreatePublicationDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
return this.service.create(file, dto, user?.id ?? null);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@BookingStaff(WRITE)
|
||||
@ApiOperation({ summary: "Update a publication's title, description, category, order or published state" })
|
||||
update(@Param("id", ParseUUIDPipe) id: string, @Body() dto: UpdatePublicationDto) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Post(":id/file")
|
||||
@BookingStaff(WRITE)
|
||||
@UseInterceptors(FileInterceptor("file", documentUploadMulterOptions))
|
||||
@ApiConsumes("multipart/form-data")
|
||||
@ApiOperation({ summary: "Replace a publication's file" })
|
||||
replaceFile(@Param("id", ParseUUIDPipe) id: string, @UploadedFile() file: Express.Multer.File) {
|
||||
return this.service.replaceFile(id, file);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@BookingStaff(WRITE)
|
||||
@ApiOperation({ summary: "Remove a publication" })
|
||||
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.service.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { MinioModule } from "../minio/minio.module";
|
||||
import { Publication } from "./entities/publication.entity";
|
||||
import { PublicationsController } from "./publications.controller";
|
||||
import { PublicationsRepository } from "./publications.repository";
|
||||
import { PublicationsService } from "./publications.service";
|
||||
import { PublicPublicationsController } from "./public-publications.controller";
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Publication]), MinioModule],
|
||||
controllers: [PublicPublicationsController, PublicationsController],
|
||||
providers: [PublicationsRepository, PublicationsService],
|
||||
exports: [PublicationsService],
|
||||
})
|
||||
export class PublicationsModule {}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { Publication } from "./entities/publication.entity";
|
||||
|
||||
@Injectable()
|
||||
export class PublicationsRepository extends BaseRepository<Publication> {
|
||||
constructor(
|
||||
@InjectRepository(Publication)
|
||||
repository: Repository<Publication>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/** Public list: published rows only, in display order. */
|
||||
findPublished(): Promise<Publication[]> {
|
||||
return this.repository.find({
|
||||
where: { published: true },
|
||||
order: { sortOrder: "ASC", publishedAt: "DESC" },
|
||||
});
|
||||
}
|
||||
|
||||
/** Admin list: every row, published or not. */
|
||||
override findAll(): Promise<Publication[]> {
|
||||
return this.repository.find({ order: { sortOrder: "ASC" } });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
PublicationSummary,
|
||||
PUBLICATION_ALLOWED_MIME_TYPES,
|
||||
PUBLICATION_FILE_PREFIX,
|
||||
} from "@edr/types";
|
||||
import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { extname } from "path";
|
||||
import { Readable } from "stream";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
import { MinioService } from "../minio/minio.service";
|
||||
import { CreatePublicationDto } from "./dto/create-publication.dto";
|
||||
import { UpdatePublicationDto } from "./dto/update-publication.dto";
|
||||
import { Publication } from "./entities/publication.entity";
|
||||
import { PublicationsRepository } from "./publications.repository";
|
||||
|
||||
@Injectable()
|
||||
export class PublicationsService {
|
||||
constructor(
|
||||
private readonly repository: PublicationsRepository,
|
||||
private readonly minio: MinioService,
|
||||
) {}
|
||||
|
||||
private assertAllowedFile(file?: Express.Multer.File): asserts file is Express.Multer.File {
|
||||
if (!file) throw new BadRequestException("No file uploaded");
|
||||
if (!(PUBLICATION_ALLOWED_MIME_TYPES as readonly string[]).includes(file.mimetype)) {
|
||||
throw new BadRequestException(
|
||||
`Unsupported file type ${file.mimetype} — PDF, Markdown and PowerPoint only`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async create(
|
||||
file: Express.Multer.File | undefined,
|
||||
dto: CreatePublicationDto,
|
||||
actorId: string | null,
|
||||
): Promise<Publication> {
|
||||
this.assertAllowedFile(file);
|
||||
|
||||
const key = `${PUBLICATION_FILE_PREFIX}${randomUUID()}${extname(file.originalname).toLowerCase()}`;
|
||||
await this.minio.uploadFile(key, file.buffer, file.mimetype);
|
||||
|
||||
const published = dto.published ?? true;
|
||||
return this.repository.create({
|
||||
title: dto.title,
|
||||
description: dto.description ?? null,
|
||||
category: dto.category ?? null,
|
||||
fileKey: key,
|
||||
fileName: file.originalname,
|
||||
fileMimeType: file.mimetype,
|
||||
fileSizeBytes: file.size,
|
||||
sortOrder: dto.sortOrder ?? 0,
|
||||
published,
|
||||
publishedAt: published ? new Date() : null,
|
||||
uploadedById: actorId,
|
||||
});
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdatePublicationDto): Promise<Publication> {
|
||||
const existing = await this.getByIdOrThrow(id);
|
||||
|
||||
const patch: Partial<Publication> = {
|
||||
...(dto.title !== undefined && { title: dto.title }),
|
||||
...(dto.description !== undefined && { description: dto.description }),
|
||||
...(dto.category !== undefined && { category: dto.category }),
|
||||
...(dto.sortOrder !== undefined && { sortOrder: dto.sortOrder }),
|
||||
};
|
||||
|
||||
if (dto.published !== undefined && dto.published !== existing.published) {
|
||||
patch.published = dto.published;
|
||||
patch.publishedAt = dto.published ? new Date() : null;
|
||||
}
|
||||
|
||||
const updated = await this.repository.update(id, patch);
|
||||
if (!updated) throw new NotFoundException(`Publication ${id} not found`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
/** Swaps the stored file for one row; the old MinIO object is dropped after the new one is saved. */
|
||||
async replaceFile(id: string, file?: Express.Multer.File): Promise<Publication> {
|
||||
this.assertAllowedFile(file);
|
||||
const existing = await this.getByIdOrThrow(id);
|
||||
|
||||
const key = `${PUBLICATION_FILE_PREFIX}${randomUUID()}${extname(file.originalname).toLowerCase()}`;
|
||||
await this.minio.uploadFile(key, file.buffer, file.mimetype);
|
||||
|
||||
const updated = await this.repository.update(id, {
|
||||
fileKey: key,
|
||||
fileName: file.originalname,
|
||||
fileMimeType: file.mimetype,
|
||||
fileSizeBytes: file.size,
|
||||
});
|
||||
|
||||
await this.minio.deleteFile(existing.fileKey);
|
||||
return updated!;
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.getByIdOrThrow(id);
|
||||
await this.repository.softDelete(id);
|
||||
}
|
||||
|
||||
/** Admin list — every row, published or not. */
|
||||
list(): Promise<Publication[]> {
|
||||
return this.repository.findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Public list — published rows only. No file URL here: a presigned MinIO
|
||||
* URL isn't reachable from the browser (see `fileViewUrl` in the portal's
|
||||
* `apiConfig.ts`); the portal builds each file's URL itself from `id` via
|
||||
* `GET /publications/:id/file`.
|
||||
*/
|
||||
async listPublic(): Promise<PublicationSummary[]> {
|
||||
const rows = await this.repository.findPublished();
|
||||
return rows.map((row) => this.toSummary(row));
|
||||
}
|
||||
|
||||
private toSummary(row: Publication): PublicationSummary {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
description: row.description ?? null,
|
||||
category: row.category ?? null,
|
||||
fileName: row.fileName,
|
||||
fileMimeType: row.fileMimeType,
|
||||
fileSizeBytes: Number(row.fileSizeBytes),
|
||||
sortOrder: row.sortOrder,
|
||||
publishedAt: row.publishedAt?.toISOString() ?? null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** For the public/staff file route: streams a published row's bytes. */
|
||||
async getPublishedFileStream(
|
||||
id: string,
|
||||
): Promise<{ stream: Readable; record: Publication }> {
|
||||
const record = await this.repository.findById(id);
|
||||
if (!record || !record.published) {
|
||||
throw new NotFoundException(`Publication ${id} not found`);
|
||||
}
|
||||
return { stream: await this.minio.getFileStream(record.fileKey), record };
|
||||
}
|
||||
|
||||
private async getByIdOrThrow(id: string): Promise<Publication> {
|
||||
const record = await this.repository.findById(id);
|
||||
if (!record) throw new NotFoundException(`Publication ${id} not found`);
|
||||
return record;
|
||||
}
|
||||
}
|
||||
@@ -2498,6 +2498,11 @@ export const FREIGHT_PERMS = {
|
||||
view: "edr_freight_app:settings:support_content:view",
|
||||
manage: "edr_freight_app:settings:support_content:manage",
|
||||
},
|
||||
// Public /publications library (PDFs, Markdown, PowerPoint), edited from the backoffice.
|
||||
publications: {
|
||||
view: "edr_freight_app:settings:publications:view",
|
||||
manage: "edr_freight_app:settings:publications:manage",
|
||||
},
|
||||
},
|
||||
support: {
|
||||
agentView: "edr_freight_app:support:agent_view",
|
||||
|
||||
Reference in New Issue
Block a user