mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 01:15:03 +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 { LogoSettingsModule } from "./modules/logo-settings/logo-settings.module";
|
||||||
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
|
import { ContractTemplatesModule } from "./modules/contract-templates/contract-templates.module";
|
||||||
import { SupportContentModule } from "./modules/support-content/support-content.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 { OtpModule } from "./modules/otp/otp.module";
|
||||||
import { HealthModule } from "./modules/health/health.module";
|
import { HealthModule } from "./modules/health/health.module";
|
||||||
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
|
import { RuleEngineModule } from "./modules/rule-engine/rule-engine.module";
|
||||||
@@ -231,6 +232,7 @@ if (!process.env.APPLICATION_NAME) {
|
|||||||
LogoSettingsModule,
|
LogoSettingsModule,
|
||||||
ContractTemplatesModule,
|
ContractTemplatesModule,
|
||||||
SupportContentModule,
|
SupportContentModule,
|
||||||
|
PublicationsModule,
|
||||||
OtpModule,
|
OtpModule,
|
||||||
HealthModule,
|
HealthModule,
|
||||||
RuleEngineModule,
|
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",
|
view: "edr_freight_app:settings:support_content:view",
|
||||||
manage: "edr_freight_app:settings:support_content:manage",
|
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: {
|
support: {
|
||||||
agentView: "edr_freight_app:support:agent_view",
|
agentView: "edr_freight_app:support:agent_view",
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import CompanyStampSettingsPage from "./pages/settings/CompanyStampSettingsPage"
|
|||||||
import LogoSettingsPage from "./pages/settings/LogoSettingsPage";
|
import LogoSettingsPage from "./pages/settings/LogoSettingsPage";
|
||||||
import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage";
|
import ContractTemplatesPage from "./pages/contract_templates/ContractTemplatesPage";
|
||||||
import PortalContentPage from "./pages/portal_content/PortalContentPage";
|
import PortalContentPage from "./pages/portal_content/PortalContentPage";
|
||||||
|
import PublicationsPage from "./pages/publications/PublicationsPage";
|
||||||
import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage";
|
import ContractTemplateEditorPage from "./pages/contract_templates/ContractTemplateEditorPage";
|
||||||
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
|
import FleetResourcePage from "./pages/fleet/FleetResourcePage";
|
||||||
import WagonTransfersPage from "./pages/wagons/WagonTransfersPage";
|
import WagonTransfersPage from "./pages/wagons/WagonTransfersPage";
|
||||||
@@ -1173,6 +1174,19 @@ const App = () => {
|
|||||||
</RequirePermission>
|
</RequirePermission>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="publications"
|
||||||
|
element={
|
||||||
|
<RequirePermission
|
||||||
|
permission={[
|
||||||
|
FREIGHT_PERMS.settings.publications.view,
|
||||||
|
FREIGHT_PERMS.settings.publications.manage,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<PublicationsPage />
|
||||||
|
</RequirePermission>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
<Route
|
<Route
|
||||||
path="configuration"
|
path="configuration"
|
||||||
|
|||||||
@@ -564,6 +564,15 @@ export const buildSidebarSections = (
|
|||||||
FREIGHT_PERMS.settings.supportContent.manage,
|
FREIGHT_PERMS.settings.supportContent.manage,
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Publications",
|
||||||
|
href: "/dashboard/publications",
|
||||||
|
icon: <FileText />,
|
||||||
|
permission: [
|
||||||
|
FREIGHT_PERMS.settings.publications.view,
|
||||||
|
FREIGHT_PERMS.settings.publications.manage,
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Audit logs",
|
label: "Audit logs",
|
||||||
href: "/dashboard/audit-logs",
|
href: "/dashboard/audit-logs",
|
||||||
|
|||||||
@@ -426,6 +426,10 @@ export const FREIGHT_PERMS = {
|
|||||||
view: "edr_freight_app:settings:support_content:view",
|
view: "edr_freight_app:settings:support_content:view",
|
||||||
manage: "edr_freight_app:settings:support_content:manage",
|
manage: "edr_freight_app:settings:support_content:manage",
|
||||||
},
|
},
|
||||||
|
publications: {
|
||||||
|
view: "edr_freight_app:settings:publications:view",
|
||||||
|
manage: "edr_freight_app:settings:publications:manage",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
staff: {
|
staff: {
|
||||||
roles: {
|
roles: {
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
|
export interface DeletePublicationDialogProps {
|
||||||
|
title: string;
|
||||||
|
onConfirm?: () => void;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeletePublicationDialog({
|
||||||
|
title,
|
||||||
|
onConfirm,
|
||||||
|
children,
|
||||||
|
}: DeletePublicationDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent className="sm:max-w-md rounded-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-xl font-bold">Delete publication?</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This will remove{" "}
|
||||||
|
<span className="font-semibold text-slate-900">{title}</span> from the
|
||||||
|
public library. It stops being downloadable immediately.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<DialogFooter className="mt-2">
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="outline">Cancel</Button>
|
||||||
|
</DialogClose>
|
||||||
|
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button onClick={onConfirm} className="bg-red-600 text-white hover:bg-red-700">
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import type { Publication } from "@edr/types";
|
||||||
|
import { useMutation } from "@tanstack/react-query";
|
||||||
|
import { Loader2, UploadCloud } from "lucide-react";
|
||||||
|
import { useRef, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
|
||||||
|
export interface EditPublicationDialogProps {
|
||||||
|
mode?: "create" | "edit";
|
||||||
|
publication?: Publication;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACCEPT =
|
||||||
|
".pdf,.md,.markdown,.ppt,.pptx,application/pdf,text/markdown,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation";
|
||||||
|
|
||||||
|
export default function EditPublicationDialog({
|
||||||
|
mode = "create",
|
||||||
|
publication,
|
||||||
|
children,
|
||||||
|
}: EditPublicationDialogProps) {
|
||||||
|
const isEdit = mode === "edit";
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [title, setTitle] = useState(publication?.title ?? "");
|
||||||
|
const [description, setDescription] = useState(publication?.description ?? "");
|
||||||
|
const [category, setCategory] = useState(publication?.category ?? "");
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [progress, setProgress] = useState<number | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const createMutation = useMutation(api.publications.create.mutationOptions());
|
||||||
|
const updateMutation = useMutation(api.publications.update.mutationOptions());
|
||||||
|
const replaceFileMutation = useMutation(api.publications.replaceFile.mutationOptions());
|
||||||
|
const pending =
|
||||||
|
createMutation.isPending || updateMutation.isPending || replaceFileMutation.isPending;
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setTitle(publication?.title ?? "");
|
||||||
|
setDescription(publication?.description ?? "");
|
||||||
|
setCategory(publication?.category ?? "");
|
||||||
|
setFile(null);
|
||||||
|
setProgress(null);
|
||||||
|
setError(null);
|
||||||
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
setError(null);
|
||||||
|
if (!title.trim()) {
|
||||||
|
setError("Title is required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isEdit && !file) {
|
||||||
|
setError("Choose a file to upload.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
title: title.trim(),
|
||||||
|
description: description.trim() || undefined,
|
||||||
|
category: category.trim() || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isEdit && publication) {
|
||||||
|
await updateMutation.mutateAsync({ id: publication.id, dto: meta });
|
||||||
|
if (file) {
|
||||||
|
await replaceFileMutation.mutateAsync({
|
||||||
|
id: publication.id,
|
||||||
|
file,
|
||||||
|
onProgress: setProgress,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (file) {
|
||||||
|
await createMutation.mutateAsync({ file, meta, onProgress: setProgress });
|
||||||
|
}
|
||||||
|
setOpen(false);
|
||||||
|
if (!isEdit) reset();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : "Something went wrong. Try again.");
|
||||||
|
} finally {
|
||||||
|
setProgress(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
setOpen(next);
|
||||||
|
if (!next) reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
|
|
||||||
|
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg rounded-3xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="text-2xl font-bold">
|
||||||
|
{isEdit ? "Edit publication" : "New publication"}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{isEdit
|
||||||
|
? "Update this document's title, description or category, or replace its file."
|
||||||
|
: "Upload a PDF, Markdown or PowerPoint file for the public library."}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-5 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Title *</Label>
|
||||||
|
<Input
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
placeholder="e.g. EDR Freight Platform Guide"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Category</Label>
|
||||||
|
<Input
|
||||||
|
value={category}
|
||||||
|
onChange={(e) => setCategory(e.target.value)}
|
||||||
|
placeholder="e.g. Guides, Reports"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Description</Label>
|
||||||
|
<Textarea
|
||||||
|
value={description}
|
||||||
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
|
placeholder="What this document covers…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>{isEdit ? "Replace file (optional)" : "File *"}</Label>
|
||||||
|
<div
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="cursor-pointer rounded-xl border-2 border-dashed border-slate-300 px-4 py-6 text-center hover:bg-slate-50"
|
||||||
|
>
|
||||||
|
{progress !== null ? (
|
||||||
|
<p className="text-sm text-slate-500">Uploading… {progress}%</p>
|
||||||
|
) : file ? (
|
||||||
|
<p className="text-sm font-medium text-slate-700">{file.name}</p>
|
||||||
|
) : isEdit && publication ? (
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Currently <span className="font-medium">{publication.fileName}</span> —
|
||||||
|
click to replace
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-1 text-slate-500">
|
||||||
|
<UploadCloud className="h-6 w-6" />
|
||||||
|
<span className="text-sm">Click to choose a PDF, Markdown or PowerPoint file</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept={ACCEPT}
|
||||||
|
hidden
|
||||||
|
onChange={(e) => setFile(e.currentTarget.files?.[0] ?? null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<p className="rounded-xl bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-2 flex justify-end gap-3">
|
||||||
|
<DialogClose asChild>
|
||||||
|
<Button variant="outline" disabled={pending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</DialogClose>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void handleSubmit()}
|
||||||
|
disabled={pending}
|
||||||
|
className="bg-[#10B981] text-white hover:bg-[#10B981]/90 disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{pending ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : isEdit ? (
|
||||||
|
"Save changes"
|
||||||
|
) : (
|
||||||
|
"Upload"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import type { Publication } from "@edr/types";
|
||||||
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Center,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Paper,
|
||||||
|
Stack,
|
||||||
|
Switch,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { FileText, Pencil, Plus, Trash2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { PageContainer } from "@/components/page";
|
||||||
|
import { formatBytes, formatDate } from "@/lib/format";
|
||||||
|
import { api } from "@/services/api";
|
||||||
|
|
||||||
|
import DeletePublicationDialog from "./DeletePublicationDialog";
|
||||||
|
import EditPublicationDialog from "./EditPublicationDialog";
|
||||||
|
|
||||||
|
/** Short label from a mime type, for the file-type badge. */
|
||||||
|
function fileKindLabel(mime: string): string {
|
||||||
|
if (mime === "application/pdf") return "PDF";
|
||||||
|
if (mime.includes("markdown")) return "Markdown";
|
||||||
|
if (mime.includes("powerpoint") || mime.includes("presentationml")) return "PowerPoint";
|
||||||
|
return "File";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backoffice admin for the freight portal's public /publications page —
|
||||||
|
* upload, edit, reorder-by-hand and unpublish PDFs, Markdown write-ups and
|
||||||
|
* PowerPoint decks about the platform.
|
||||||
|
*/
|
||||||
|
export default function PublicationsPage() {
|
||||||
|
const { data, isLoading, isError } = useQuery(api.publications.list.queryOptions());
|
||||||
|
const updateMutation = useMutation(api.publications.update.mutationOptions());
|
||||||
|
const removeMutation = useMutation(api.publications.remove.mutationOptions());
|
||||||
|
|
||||||
|
const publications = [...(data ?? [])].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageContainer>
|
||||||
|
<Stack gap="lg">
|
||||||
|
<Group justify="space-between" align="flex-start" wrap="wrap">
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Title order={2}>Publications</Title>
|
||||||
|
<Text size="sm" c="dimmed" maw={560}>
|
||||||
|
PDFs, Markdown write-ups and PowerPoint decks shown on the public
|
||||||
|
/publications page — no login required to view them.
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<EditPublicationDialog>
|
||||||
|
<Button leftSection={<Plus size={16} />} color="edr-green">
|
||||||
|
New publication
|
||||||
|
</Button>
|
||||||
|
</EditPublicationDialog>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<Paper withBorder radius="lg" p="lg">
|
||||||
|
{isLoading ? (
|
||||||
|
<Center py="xl">
|
||||||
|
<Loader color="edr-green" />
|
||||||
|
</Center>
|
||||||
|
) : isError ? (
|
||||||
|
<Text c="dimmed">Could not load publications.</Text>
|
||||||
|
) : publications.length === 0 ? (
|
||||||
|
<Stack align="center" gap="md" py="xl">
|
||||||
|
<FileText size={32} color="var(--mantine-color-gray-5)" />
|
||||||
|
<Text c="dimmed">No publications yet.</Text>
|
||||||
|
<EditPublicationDialog>
|
||||||
|
<Button variant="light" color="edr-green">
|
||||||
|
Upload the first one
|
||||||
|
</Button>
|
||||||
|
</EditPublicationDialog>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Table striped highlightOnHover verticalSpacing="sm">
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>Title</Table.Th>
|
||||||
|
<Table.Th>Category</Table.Th>
|
||||||
|
<Table.Th>Type</Table.Th>
|
||||||
|
<Table.Th>Size</Table.Th>
|
||||||
|
<Table.Th>Published</Table.Th>
|
||||||
|
<Table.Th>Updated</Table.Th>
|
||||||
|
<Table.Th />
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>
|
||||||
|
{publications.map((pub: Publication) => (
|
||||||
|
<Table.Tr key={pub.id}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fw={600} size="sm">
|
||||||
|
{pub.title}
|
||||||
|
</Text>
|
||||||
|
{pub.description ? (
|
||||||
|
<Text size="xs" c="dimmed" lineClamp={1}>
|
||||||
|
{pub.description}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{pub.category ? (
|
||||||
|
<Badge variant="light" color="gray">
|
||||||
|
{pub.category}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
—
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Badge variant="light" color="edr-green">
|
||||||
|
{fileKindLabel(pub.fileMimeType)}
|
||||||
|
</Badge>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm">{formatBytes(pub.fileSizeBytes)}</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Tooltip label={pub.published ? "Visible on the public page" : "Hidden from the public page"}>
|
||||||
|
<Switch
|
||||||
|
checked={pub.published}
|
||||||
|
color="edr-green"
|
||||||
|
onChange={(e) =>
|
||||||
|
updateMutation.mutate({
|
||||||
|
id: pub.id,
|
||||||
|
dto: { published: e.currentTarget.checked },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{formatDate(pub.updatedAt)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap={4} justify="flex-end" wrap="nowrap">
|
||||||
|
<EditPublicationDialog mode="edit" publication={pub}>
|
||||||
|
<ActionIcon variant="subtle" color="gray" aria-label="Edit">
|
||||||
|
<Pencil size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</EditPublicationDialog>
|
||||||
|
<DeletePublicationDialog
|
||||||
|
title={pub.title}
|
||||||
|
onConfirm={() => removeMutation.mutate({ id: pub.id })}
|
||||||
|
>
|
||||||
|
<ActionIcon variant="subtle" color="red" aria-label="Delete">
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</DeletePublicationDialog>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</Paper>
|
||||||
|
</Stack>
|
||||||
|
</PageContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { Freight, PaginatedResponse } from "@edr/types";
|
import type { Freight, PaginatedResponse, Publication } from "@edr/types";
|
||||||
|
|
||||||
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
import { QUERY_KEYS } from "@/constants/QUERY_KEYS";
|
||||||
import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
|
import type { FleetResourceSlug } from "@/pages/fleet/config/resources";
|
||||||
@@ -191,6 +191,7 @@ import type { EimsInvoiceStatusView, EimsModeOfPayment, EimsReceiptView, EimsVer
|
|||||||
import { invoicesService } from "./invoices.service";
|
import { invoicesService } from "./invoices.service";
|
||||||
import { dropdownSettingsService } from "./dropdownSettings.service";
|
import { dropdownSettingsService } from "./dropdownSettings.service";
|
||||||
import { fileUploadSettingsService } from "./fileUploadSettings.service";
|
import { fileUploadSettingsService } from "./fileUploadSettings.service";
|
||||||
|
import { publicationsService, type UpdatePublicationPayload } from "./publications.service";
|
||||||
import {
|
import {
|
||||||
fleetService,
|
fleetService,
|
||||||
type FleetListFilters,
|
type FleetListFilters,
|
||||||
@@ -2934,6 +2935,52 @@ export const api = {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
publications: {
|
||||||
|
list: endpoint<void, Publication[]>(
|
||||||
|
"publications",
|
||||||
|
"list",
|
||||||
|
publicationsService.list,
|
||||||
|
),
|
||||||
|
|
||||||
|
create: endpoint<
|
||||||
|
{ file: File; meta: UpdatePublicationPayload & { title: string }; onProgress?: (percent: number | null) => void },
|
||||||
|
Publication
|
||||||
|
>(
|
||||||
|
"publications",
|
||||||
|
"create",
|
||||||
|
({ file, meta, onProgress }) => publicationsService.create(file, meta, onProgress),
|
||||||
|
undefined,
|
||||||
|
() => [["publications"]],
|
||||||
|
),
|
||||||
|
|
||||||
|
update: endpoint<{ id: string; dto: UpdatePublicationPayload }, Publication>(
|
||||||
|
"publications",
|
||||||
|
"update",
|
||||||
|
({ id, dto }) => publicationsService.update(id, dto),
|
||||||
|
undefined,
|
||||||
|
() => [["publications"]],
|
||||||
|
),
|
||||||
|
|
||||||
|
replaceFile: endpoint<
|
||||||
|
{ id: string; file: File; onProgress?: (percent: number | null) => void },
|
||||||
|
Publication
|
||||||
|
>(
|
||||||
|
"publications",
|
||||||
|
"replaceFile",
|
||||||
|
({ id, file, onProgress }) => publicationsService.replaceFile(id, file, onProgress),
|
||||||
|
undefined,
|
||||||
|
() => [["publications"]],
|
||||||
|
),
|
||||||
|
|
||||||
|
remove: endpoint<{ id: string }, void>(
|
||||||
|
"publications",
|
||||||
|
"remove",
|
||||||
|
({ id }) => publicationsService.remove(id),
|
||||||
|
undefined,
|
||||||
|
() => [["publications"]],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
|
||||||
dropdownSettings: {
|
dropdownSettings: {
|
||||||
list: endpoint<void, DropdownSetting[]>(
|
list: endpoint<void, DropdownSetting[]>(
|
||||||
"dropdown-settings",
|
"dropdown-settings",
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import type { Publication } from "@edr/types";
|
||||||
|
|
||||||
|
import { api as client } from "../auth/http";
|
||||||
|
|
||||||
|
const BASE = "/publications";
|
||||||
|
|
||||||
|
export interface UpdatePublicationPayload {
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
category?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
published?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The freight portal's public document library (/publications), managed here.
|
||||||
|
* Every write is multipart because create/replaceFile carry a real file — the
|
||||||
|
* client's response interceptor already unwraps the `{ success, data }`
|
||||||
|
* envelope, so each method stays a one-liner.
|
||||||
|
*/
|
||||||
|
export const publicationsService = {
|
||||||
|
async list(): Promise<Publication[]> {
|
||||||
|
const { data } = await client.get<Publication[]>(`${BASE}/admin`);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(
|
||||||
|
file: File,
|
||||||
|
meta: UpdatePublicationPayload & { title: string },
|
||||||
|
onProgress?: (percent: number | null) => void,
|
||||||
|
): Promise<Publication> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
Object.entries(meta).forEach(([key, value]) => {
|
||||||
|
if (value !== undefined) form.append(key, String(value));
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data } = await client.post<Publication>(BASE, form, {
|
||||||
|
timeout: 2 * 60 * 1000,
|
||||||
|
onUploadProgress: (event) =>
|
||||||
|
onProgress?.(
|
||||||
|
event.total ? Math.round((event.loaded / event.total) * 100) : null,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(id: string, dto: UpdatePublicationPayload): Promise<Publication> {
|
||||||
|
const { data } = await client.patch<Publication>(`${BASE}/${id}`, dto);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async replaceFile(
|
||||||
|
id: string,
|
||||||
|
file: File,
|
||||||
|
onProgress?: (percent: number | null) => void,
|
||||||
|
): Promise<Publication> {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("file", file);
|
||||||
|
|
||||||
|
const { data } = await client.post<Publication>(`${BASE}/${id}/file`, form, {
|
||||||
|
timeout: 2 * 60 * 1000,
|
||||||
|
onUploadProgress: (event) =>
|
||||||
|
onProgress?.(
|
||||||
|
event.total ? Math.round((event.loaded / event.total) * 100) : null,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
|
||||||
|
async remove(id: string): Promise<void> {
|
||||||
|
await client.delete(`${BASE}/${id}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -78,6 +78,7 @@ import FaqPage from "./pages/support/FaqPage";
|
|||||||
import HelpPage from "./pages/support/HelpPage";
|
import HelpPage from "./pages/support/HelpPage";
|
||||||
import PrivacyPolicyPage from "./pages/support/PrivacyPolicyPage";
|
import PrivacyPolicyPage from "./pages/support/PrivacyPolicyPage";
|
||||||
import TermsPage from "./pages/support/TermsPage";
|
import TermsPage from "./pages/support/TermsPage";
|
||||||
|
import PublicationsPage from "./pages/publications/PublicationsPage";
|
||||||
import TrackingPage from "./pages/tracking/TrackingPage";
|
import TrackingPage from "./pages/tracking/TrackingPage";
|
||||||
|
|
||||||
function FullScreenSpinner() {
|
function FullScreenSpinner() {
|
||||||
@@ -427,6 +428,7 @@ const App = () => {
|
|||||||
<Route path="/faq" element={<FaqPage />} />
|
<Route path="/faq" element={<FaqPage />} />
|
||||||
<Route path="/privacy" element={<PrivacyPolicyPage />} />
|
<Route path="/privacy" element={<PrivacyPolicyPage />} />
|
||||||
<Route path="/terms" element={<TermsPage />} />
|
<Route path="/terms" element={<TermsPage />} />
|
||||||
|
<Route path="/publications" element={<PublicationsPage />} />
|
||||||
|
|
||||||
{/* Auth pages — inaccessible once logged in */}
|
{/* Auth pages — inaccessible once logged in */}
|
||||||
<Route element={<RedirectIfAuthed />}>
|
<Route element={<RedirectIfAuthed />}>
|
||||||
|
|||||||
@@ -229,6 +229,10 @@ export const URL_CONSTANTS = {
|
|||||||
PUBLIC: "/api/support-content",
|
PUBLIC: "/api/support-content",
|
||||||
},
|
},
|
||||||
|
|
||||||
|
PUBLICATIONS: {
|
||||||
|
PUBLIC: "/api/publications",
|
||||||
|
},
|
||||||
|
|
||||||
EMPTY_RETURN_REQUESTS: {
|
EMPTY_RETURN_REQUESTS: {
|
||||||
BASE: "/api/empty-return-requests",
|
BASE: "/api/empty-return-requests",
|
||||||
ELIGIBILITY: (bookingId: string) => `/api/empty-return-requests/eligibility/${bookingId}`,
|
ELIGIBILITY: (bookingId: string) => `/api/empty-return-requests/eligibility/${bookingId}`,
|
||||||
|
|||||||
@@ -11,3 +11,13 @@ export function fileViewUrl(fileId: string, download = false): string {
|
|||||||
const base = `${API_BASE_URL}/api/files/${fileId}`;
|
const base = `${API_BASE_URL}/api/files/${fileId}`;
|
||||||
return download ? `${base}?download=1` : base;
|
return download ? `${base}?download=1` : base;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* URL that streams a public publication's file through the API by its UUID.
|
||||||
|
* Same reasoning as `fileViewUrl`: a presigned MinIO URL is not reachable from
|
||||||
|
* the browser here, so the bytes are streamed through the API instead.
|
||||||
|
*/
|
||||||
|
export function publicationFileUrl(id: string, download = false): string {
|
||||||
|
const base = `${API_BASE_URL}/api/publications/${id}/file`;
|
||||||
|
return download ? `${base}?download=1` : base;
|
||||||
|
}
|
||||||
|
|||||||
25
apps/edr-freight-web/portal/src/hooks/usePublications.ts
Normal file
25
apps/edr-freight-web/portal/src/hooks/usePublications.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { PublicationSummary } from "@edr/types";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
import { URL_CONSTANTS } from "@/constants/URLS";
|
||||||
|
import type { ApiResponse } from "@/types/apiResponse";
|
||||||
|
import { client } from "@/utils/api";
|
||||||
|
import { unwrap } from "@/utils/endpoint";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The public /publications library — PDFs, Markdown write-ups and PowerPoint
|
||||||
|
* decks about the platform. Unauthenticated, same as `usePortalContent`; the
|
||||||
|
* shared axios client only attaches a token when the cookie exists.
|
||||||
|
*/
|
||||||
|
export function usePublications() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["publications"],
|
||||||
|
queryFn: async (): Promise<PublicationSummary[]> => {
|
||||||
|
const response = await client.get<ApiResponse<PublicationSummary[]>>(
|
||||||
|
URL_CONSTANTS.PUBLICATIONS.PUBLIC,
|
||||||
|
);
|
||||||
|
return unwrap(response.data);
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -142,6 +142,7 @@ const navLinks = [
|
|||||||
{ label: "Live ops", href: "#showcase" },
|
{ label: "Live ops", href: "#showcase" },
|
||||||
{ label: "Corridors", href: "#corridors" },
|
{ label: "Corridors", href: "#corridors" },
|
||||||
{ label: "How it works", href: "#how" },
|
{ label: "How it works", href: "#how" },
|
||||||
|
{ label: "Publications", href: "/publications" },
|
||||||
{ label: "Contact", href: "#contact" },
|
{ label: "Contact", href: "#contact" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -534,15 +535,27 @@ export default function EDRFreightLandingPage() {
|
|||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<nav className="hidden items-center gap-9 lg:flex">
|
<nav className="hidden items-center gap-9 lg:flex">
|
||||||
{navLinks.map((link) => (
|
{navLinks.map((link) =>
|
||||||
<a
|
// Same-page anchors (#features) scroll; a route (/publications)
|
||||||
key={link.href}
|
// needs router navigation instead.
|
||||||
href={link.href}
|
link.href.startsWith("/") ? (
|
||||||
className="text-sm font-medium text-slate-300 transition hover:text-white"
|
<Link
|
||||||
>
|
key={link.href}
|
||||||
{link.label}
|
to={link.href}
|
||||||
</a>
|
className="text-sm font-medium text-slate-300 transition hover:text-white"
|
||||||
))}
|
>
|
||||||
|
{link.label}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<a
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
className="text-sm font-medium text-slate-300 transition hover:text-white"
|
||||||
|
>
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
)}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -1248,6 +1261,7 @@ export default function EDRFreightLandingPage() {
|
|||||||
links: [
|
links: [
|
||||||
{ label: "Help & Support", to: "/help" },
|
{ label: "Help & Support", to: "/help" },
|
||||||
{ label: "FAQ", to: "/faq" },
|
{ label: "FAQ", to: "/faq" },
|
||||||
|
{ label: "Publications", to: "/publications" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
import type { PublicationSummary } from "@edr/types";
|
||||||
|
import { useFileViewer } from "@edr/ui-common";
|
||||||
|
import { FileText, Presentation, Download, X } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { publicationFileUrl } from "@/constants/apiConfig";
|
||||||
|
import { usePublications } from "@/hooks/usePublications";
|
||||||
|
|
||||||
|
import { Markdown } from "../support/Markdown";
|
||||||
|
import { DocShell } from "../support/DocShell";
|
||||||
|
|
||||||
|
const MARKDOWN_MIMES = new Set(["text/markdown", "text/x-markdown"]);
|
||||||
|
|
||||||
|
function isMarkdown(pub: PublicationSummary): boolean {
|
||||||
|
return MARKDOWN_MIMES.has(pub.fileMimeType) || pub.fileName.toLowerCase().endsWith(".md");
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileKindIcon(pub: PublicationSummary) {
|
||||||
|
if (isMarkdown(pub)) return FileText;
|
||||||
|
if (pub.fileMimeType.includes("powerpoint") || pub.fileMimeType.includes("presentationml")) {
|
||||||
|
return Presentation;
|
||||||
|
}
|
||||||
|
return FileText;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileKindLabel(pub: PublicationSummary): string {
|
||||||
|
if (pub.fileMimeType === "application/pdf") return "PDF";
|
||||||
|
if (isMarkdown(pub)) return "Markdown";
|
||||||
|
if (pub.fileMimeType.includes("powerpoint") || pub.fileMimeType.includes("presentationml")) {
|
||||||
|
return "PowerPoint";
|
||||||
|
}
|
||||||
|
return "Document";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public library of platform documentation: PDFs, Markdown write-ups and
|
||||||
|
* PowerPoint decks, curated from the backoffice. No login required — same
|
||||||
|
* chrome as /help, /faq and the legal pages.
|
||||||
|
*
|
||||||
|
* Markdown gets a real in-page reader (`Markdown`, the same renderer the
|
||||||
|
* legal pages use) rather than routing through the generic `FileViewerModal`,
|
||||||
|
* whose "text" kind is a raw iframe with no markdown rendering. PDF and
|
||||||
|
* PowerPoint use that shared viewer as-is.
|
||||||
|
*/
|
||||||
|
export default function PublicationsPage() {
|
||||||
|
const { data: publications, isLoading } = usePublications();
|
||||||
|
const { view, viewer } = useFileViewer();
|
||||||
|
const [reading, setReading] = useState<PublicationSummary | null>(null);
|
||||||
|
const [markdownText, setMarkdownText] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const openMarkdown = async (pub: PublicationSummary) => {
|
||||||
|
setReading(pub);
|
||||||
|
setMarkdownText(null);
|
||||||
|
const response = await fetch(publicationFileUrl(pub.id));
|
||||||
|
setMarkdownText(await response.text());
|
||||||
|
};
|
||||||
|
|
||||||
|
const open = (pub: PublicationSummary) => {
|
||||||
|
if (isMarkdown(pub)) {
|
||||||
|
void openMarkdown(pub);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
view({
|
||||||
|
name: pub.fileName,
|
||||||
|
url: publicationFileUrl(pub.id),
|
||||||
|
mimeType: pub.fileMimeType,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DocShell
|
||||||
|
current="/publications"
|
||||||
|
title="Publications"
|
||||||
|
subtitle="Guides, reports and presentations about the EDR Freight platform — open them here or download a copy."
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-muted-foreground">Loading…</p>
|
||||||
|
) : !publications || publications.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground">Nothing published yet — check back soon.</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{publications.map((pub) => {
|
||||||
|
const Icon = fileKindIcon(pub);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={pub.id}
|
||||||
|
className="flex flex-col rounded-[32px] border border-border bg-background p-6 shadow-sm"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex size-11 items-center justify-center rounded-2xl bg-accent text-foreground">
|
||||||
|
<Icon className="size-5" />
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline">{fileKindLabel(pub)}</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="mt-4 font-bold tracking-tight">{pub.title}</h3>
|
||||||
|
{pub.description ? (
|
||||||
|
<p className="mt-2 line-clamp-3 text-sm text-muted-foreground">
|
||||||
|
{pub.description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
{pub.category ? <span>{pub.category}</span> : null}
|
||||||
|
{pub.category ? <span aria-hidden>·</span> : null}
|
||||||
|
<span>{formatSize(pub.fileSizeBytes)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 flex gap-2">
|
||||||
|
<Button className="flex-1" onClick={() => open(pub)}>
|
||||||
|
{isMarkdown(pub) ? "Read" : "View"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="icon" asChild>
|
||||||
|
<a href={publicationFileUrl(pub.id, true)} aria-label="Download">
|
||||||
|
<Download className="size-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PDF / office-file preview for everything except Markdown. */}
|
||||||
|
{viewer}
|
||||||
|
|
||||||
|
{/* In-page markdown reader — mirrors the legal pages' rendering. */}
|
||||||
|
{reading ? (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||||
|
<div className="flex max-h-[85vh] w-full max-w-2xl flex-col overflow-hidden rounded-[32px] bg-background shadow-xl">
|
||||||
|
<div className="flex items-center justify-between border-b border-border px-6 py-4">
|
||||||
|
<h2 className="font-bold">{reading.title}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setReading(null)}
|
||||||
|
aria-label="Close"
|
||||||
|
className="rounded-full p-1 hover:bg-accent"
|
||||||
|
>
|
||||||
|
<X className="size-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-y-auto px-6 py-4">
|
||||||
|
{markdownText === null ? (
|
||||||
|
<p className="text-muted-foreground">Loading…</p>
|
||||||
|
) : (
|
||||||
|
<Markdown>{markdownText}</Markdown>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DocShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import { Markdown } from "./Markdown";
|
|||||||
const DOC_LINKS = [
|
const DOC_LINKS = [
|
||||||
{ to: "/help", label: "Help & Support" },
|
{ to: "/help", label: "Help & Support" },
|
||||||
{ to: "/faq", label: "FAQ" },
|
{ to: "/faq", label: "FAQ" },
|
||||||
|
{ to: "/publications", label: "Publications" },
|
||||||
{ to: "/privacy", label: "Privacy Policy" },
|
{ to: "/privacy", label: "Privacy Policy" },
|
||||||
{ to: "/terms", label: "Terms of Service" },
|
{ to: "/terms", label: "Terms of Service" },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export * from "./booking-window-ws";
|
|||||||
export * from "./support-chat";
|
export * from "./support-chat";
|
||||||
export * from "./portal-content";
|
export * from "./portal-content";
|
||||||
export * from "./portal-content.defaults";
|
export * from "./portal-content.defaults";
|
||||||
|
export * from "./publications";
|
||||||
|
|
||||||
export enum TradeDirection {
|
export enum TradeDirection {
|
||||||
IMPORT = "IMPORT",
|
IMPORT = "IMPORT",
|
||||||
|
|||||||
43
packages/types/src/freight/publications.ts
Normal file
43
packages/types/src/freight/publications.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Public document library for the freight portal (/publications): PDFs,
|
||||||
|
* Markdown write-ups and PowerPoint decks about the platform, uploaded and
|
||||||
|
* curated from the backoffice. Unlike `support-content`'s five fixed slugs,
|
||||||
|
* this is a real table of many rows — each row is one whole file, replaced
|
||||||
|
* wholesale rather than edited in place, so there is no version history here.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** MinIO key prefix every uploaded publication file is stored under. */
|
||||||
|
export const PUBLICATION_FILE_PREFIX = "publications/";
|
||||||
|
|
||||||
|
/** Mime types accepted for a publication upload. */
|
||||||
|
export const PUBLICATION_ALLOWED_MIME_TYPES = [
|
||||||
|
"application/pdf",
|
||||||
|
"text/markdown",
|
||||||
|
"text/x-markdown",
|
||||||
|
"application/vnd.ms-powerpoint",
|
||||||
|
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** One document in the public library. */
|
||||||
|
export interface Publication {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
category: string | null;
|
||||||
|
fileName: string;
|
||||||
|
fileMimeType: string;
|
||||||
|
fileSizeBytes: number;
|
||||||
|
sortOrder: number;
|
||||||
|
published: boolean;
|
||||||
|
publishedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shape the anonymous portal reads — no `fileKey` (that's a MinIO object
|
||||||
|
* name, not a browser-reachable URL — a presigned MinIO URL isn't reachable
|
||||||
|
* from the browser here, see `apiConfig.ts`'s `fileViewUrl`). The portal
|
||||||
|
* builds the file's URL itself from `id` via `GET /api/publications/:id/file`.
|
||||||
|
*/
|
||||||
|
export type PublicationSummary = Omit<Publication, "published">;
|
||||||
Reference in New Issue
Block a user