feat: add publication

This commit is contained in:
ghost2023
2026-09-04 14:43:17 +03:00
parent ad791e2074
commit 0dd0e8d992
28 changed files with 1323 additions and 10 deletions

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
import { PartialType } from "@nestjs/mapped-types";
import { CreatePublicationDto } from "./create-publication.dto";
export class UpdatePublicationDto extends PartialType(CreatePublicationDto) {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",