mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 08:32:54 +00:00
file upload settings
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import {
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
} from "class-validator";
|
||||
|
||||
export class CreateFileUploadFieldDto {
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
fileKey!: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
fileLabel!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
helpText?: string;
|
||||
|
||||
@IsBoolean()
|
||||
isRequired!: boolean;
|
||||
|
||||
@IsBoolean()
|
||||
isMultiple!: boolean;
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(50)
|
||||
maxFiles!: number;
|
||||
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsString({ each: true })
|
||||
allowedExtensions!: string[];
|
||||
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(500)
|
||||
maxSizeMb!: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
order?: number;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Type } from "class-transformer";
|
||||
import {
|
||||
IsArray,
|
||||
IsEnum,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
ValidateNested,
|
||||
} from "class-validator";
|
||||
|
||||
import { CreateFileUploadFieldDto } from "./create-file-upload-field.dto";
|
||||
|
||||
export enum FileUploadEntityDto {
|
||||
Customer = "customer",
|
||||
Booking = "booking",
|
||||
Consignment = "consignment",
|
||||
Shipment = "shipment",
|
||||
Invoice = "invoice",
|
||||
Train = "train",
|
||||
Other = "other",
|
||||
}
|
||||
|
||||
export class CreateFileUploadSettingDto {
|
||||
@IsString()
|
||||
@MaxLength(128)
|
||||
code!: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(256)
|
||||
label!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsEnum(FileUploadEntityDto)
|
||||
entity!: FileUploadEntityDto;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CreateFileUploadFieldDto)
|
||||
fields?: CreateFileUploadFieldDto[];
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { PartialType } from "@nestjs/swagger";
|
||||
|
||||
import { CreateFileUploadFieldDto } from "./create-file-upload-field.dto";
|
||||
|
||||
export class UpdateFileUploadFieldDto extends PartialType(
|
||||
CreateFileUploadFieldDto,
|
||||
) {}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { OmitType, PartialType } from "@nestjs/swagger";
|
||||
|
||||
import { CreateFileUploadSettingDto } from "./create-file-upload-setting.dto";
|
||||
|
||||
export class UpdateFileUploadSettingDto extends PartialType(
|
||||
OmitType(CreateFileUploadSettingDto, ["fields"] as const),
|
||||
) {}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import {
|
||||
Check,
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
} from "typeorm";
|
||||
|
||||
import { FileUploadSetting } from "./file-upload-setting.entity";
|
||||
|
||||
@Entity({ name: "file_upload_fields" })
|
||||
@Index(["settingId", "fileKey"], { unique: true })
|
||||
@Check(`"max_files" > 0`)
|
||||
@Check(`"max_size_mb" > 0`)
|
||||
export class FileUploadField extends BaseEntity {
|
||||
@ManyToOne(() => FileUploadSetting, (setting) => setting.fields, {
|
||||
onDelete: "CASCADE",
|
||||
})
|
||||
@JoinColumn({ name: "setting_id" })
|
||||
setting!: FileUploadSetting;
|
||||
|
||||
@Column({ name: "setting_id", type: "uuid" })
|
||||
settingId!: string;
|
||||
|
||||
@Column({ name: "file_key", type: "varchar", length: 128 })
|
||||
fileKey!: string;
|
||||
|
||||
@Column({ name: "file_label", type: "varchar", length: 256 })
|
||||
fileLabel!: string;
|
||||
|
||||
@Column({ name: "help_text", type: "text", nullable: true })
|
||||
helpText?: string | null;
|
||||
|
||||
@Column({ name: "is_required", type: "boolean", default: false })
|
||||
isRequired!: boolean;
|
||||
|
||||
@Column({ name: "is_multiple", type: "boolean", default: false })
|
||||
isMultiple!: boolean;
|
||||
|
||||
@Column({ name: "max_files", type: "integer", default: 1 })
|
||||
maxFiles!: number;
|
||||
|
||||
@Column({
|
||||
name: "allowed_extensions",
|
||||
type: "text",
|
||||
array: true,
|
||||
default: () => "'{}'::text[]",
|
||||
})
|
||||
allowedExtensions!: string[];
|
||||
|
||||
@Column({ name: "max_size_mb", type: "integer", default: 10 })
|
||||
maxSizeMb!: number;
|
||||
|
||||
@Column({ name: "display_order", type: "integer", default: 0 })
|
||||
displayOrder!: number;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { BaseEntity } from "@edr/api-common";
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
OneToMany,
|
||||
} from "typeorm";
|
||||
|
||||
import { FileUploadField } from "./file-upload-field.entity";
|
||||
|
||||
@Entity({ name: "file_upload_settings" })
|
||||
@Index(["code"], { unique: true })
|
||||
export class FileUploadSetting extends BaseEntity {
|
||||
@Column({
|
||||
name: "code",
|
||||
type: "varchar",
|
||||
length: 128,
|
||||
unique: true,
|
||||
})
|
||||
code!: string;
|
||||
|
||||
@Column({
|
||||
name: "label",
|
||||
type: "varchar",
|
||||
length: 256,
|
||||
})
|
||||
label!: string;
|
||||
|
||||
@Column({
|
||||
name: "description",
|
||||
type: "text",
|
||||
nullable: true,
|
||||
})
|
||||
description?: string | null;
|
||||
|
||||
@Column({
|
||||
name: "entity",
|
||||
type: "varchar",
|
||||
length: 32,
|
||||
default: "other",
|
||||
})
|
||||
entity!: string;
|
||||
|
||||
@OneToMany(
|
||||
() => FileUploadField,
|
||||
(field) => field.setting,
|
||||
{
|
||||
cascade: true,
|
||||
}
|
||||
)
|
||||
fields!: FileUploadField[];
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
} from "@nestjs/common";
|
||||
import { ApiOperation, ApiTags } from "@nestjs/swagger";
|
||||
|
||||
import { CreateFileUploadFieldDto } from "./dto/create-file-upload-field.dto";
|
||||
import { CreateFileUploadSettingDto } from "./dto/create-file-upload-setting.dto";
|
||||
import { UpdateFileUploadFieldDto } from "./dto/update-file-upload-field.dto";
|
||||
import { UpdateFileUploadSettingDto } from "./dto/update-file-upload-setting.dto";
|
||||
import { FileUploadSettingsService } from "./file-upload-settings.service";
|
||||
|
||||
@ApiTags("file-upload-settings")
|
||||
@Controller("file-upload-settings")
|
||||
export class FileUploadSettingsController {
|
||||
constructor(private readonly service: FileUploadSettingsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: "List all file upload settings" })
|
||||
list() {
|
||||
return this.service.list();
|
||||
}
|
||||
|
||||
@Get(":id")
|
||||
@ApiOperation({ summary: "Get a file upload setting by ID" })
|
||||
getById(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.service.getById(id);
|
||||
}
|
||||
|
||||
@Get("by-code/:code")
|
||||
@ApiOperation({ summary: "Get a file upload setting by its stable code" })
|
||||
getByCode(@Param("code") code: string) {
|
||||
return this.service.getByCode(code);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: "Create a new file upload setting" })
|
||||
create(@Body() dto: CreateFileUploadSettingDto) {
|
||||
return this.service.create(dto);
|
||||
}
|
||||
|
||||
@Patch(":id")
|
||||
@ApiOperation({ summary: "Update a file upload setting's metadata" })
|
||||
update(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: UpdateFileUploadSettingDto,
|
||||
) {
|
||||
return this.service.update(id, dto);
|
||||
}
|
||||
|
||||
@Delete(":id")
|
||||
@ApiOperation({ summary: "Soft-delete a file upload setting" })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
remove(@Param("id", ParseUUIDPipe) id: string) {
|
||||
return this.service.remove(id);
|
||||
}
|
||||
|
||||
/* ------------------------- field routes ------------------------- */
|
||||
|
||||
@Put(":id/fields")
|
||||
@ApiOperation({ summary: "Replace the full field list for a setting" })
|
||||
replaceFields(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() fields: CreateFileUploadFieldDto[],
|
||||
) {
|
||||
return this.service.replaceFields(id, fields);
|
||||
}
|
||||
|
||||
@Post(":id/fields")
|
||||
@ApiOperation({ summary: "Append a single field to a setting" })
|
||||
addField(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: CreateFileUploadFieldDto,
|
||||
) {
|
||||
return this.service.addField(id, dto);
|
||||
}
|
||||
|
||||
@Patch("fields/:fieldId")
|
||||
@ApiOperation({ summary: "Update a single field" })
|
||||
updateField(
|
||||
@Param("fieldId", ParseUUIDPipe) fieldId: string,
|
||||
@Body() dto: UpdateFileUploadFieldDto,
|
||||
) {
|
||||
return this.service.updateField(fieldId, dto);
|
||||
}
|
||||
|
||||
@Delete("fields/:fieldId")
|
||||
@ApiOperation({ summary: "Soft-delete a single field" })
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
removeField(@Param("fieldId", ParseUUIDPipe) fieldId: string) {
|
||||
return this.service.removeField(fieldId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { TypeOrmModule } from "@nestjs/typeorm";
|
||||
|
||||
import { FileUploadField } from "./entities/file-upload-field.entity";
|
||||
import { FileUploadSetting } from "./entities/file-upload-setting.entity";
|
||||
import { FileUploadSettingsController } from "./file-upload-settings.controller";
|
||||
import { FileUploadSettingsRepository } from "./file-upload-settings.repository";
|
||||
import { FileUploadSettingsService } from "./file-upload-settings.service";
|
||||
import { FILE_UPLOAD_SETTINGS_REPOSITORY } from "./interfaces/file-upload-settings.repository.interface";
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([FileUploadSetting, FileUploadField])],
|
||||
controllers: [FileUploadSettingsController],
|
||||
providers: [
|
||||
FileUploadSettingsRepository,
|
||||
{
|
||||
// Bind the interface token to the concrete TypeORM repository so the
|
||||
// service can inject the abstraction (handy for tests / swap-out).
|
||||
provide: FILE_UPLOAD_SETTINGS_REPOSITORY,
|
||||
useExisting: FileUploadSettingsRepository,
|
||||
},
|
||||
FileUploadSettingsService,
|
||||
],
|
||||
exports: [FileUploadSettingsService],
|
||||
})
|
||||
export class FileUploadSettingsModule {}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { BaseRepository } from "@edr/api-common";
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { InjectRepository } from "@nestjs/typeorm";
|
||||
import { Repository } from "typeorm";
|
||||
|
||||
import { FileUploadField } from "./entities/file-upload-field.entity";
|
||||
import { FileUploadSetting } from "./entities/file-upload-setting.entity";
|
||||
import type { IFileUploadSettingsRepository } from "./interfaces/file-upload-settings.repository.interface";
|
||||
|
||||
@Injectable()
|
||||
export class FileUploadSettingsRepository
|
||||
extends BaseRepository<FileUploadSetting>
|
||||
implements IFileUploadSettingsRepository
|
||||
{
|
||||
constructor(
|
||||
@InjectRepository(FileUploadSetting)
|
||||
repository: Repository<FileUploadSetting>,
|
||||
@InjectRepository(FileUploadField)
|
||||
private readonly fieldsRepository: Repository<FileUploadField>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/** Look up a setting by its stable code. */
|
||||
findByCode(code: string): Promise<FileUploadSetting | null> {
|
||||
return this.repository.findOne({
|
||||
where: { code },
|
||||
relations: { fields: true },
|
||||
});
|
||||
}
|
||||
|
||||
override findAll(): Promise<FileUploadSetting[]> {
|
||||
return this.repository.find({
|
||||
order: { label: "ASC" },
|
||||
relations: { fields: true },
|
||||
});
|
||||
}
|
||||
|
||||
/** Replace the whole field list for a setting. Returns the saved rows. */
|
||||
async replaceFields(
|
||||
settingId: string,
|
||||
fields: Array<Partial<FileUploadField>>,
|
||||
): Promise<FileUploadField[]> {
|
||||
await this.fieldsRepository.delete({ settingId });
|
||||
if (fields.length === 0) return [];
|
||||
const entities = fields.map((f, idx) =>
|
||||
this.fieldsRepository.create({
|
||||
...f,
|
||||
settingId,
|
||||
displayOrder: f.displayOrder ?? idx + 1,
|
||||
}),
|
||||
);
|
||||
return this.fieldsRepository.save(entities);
|
||||
}
|
||||
|
||||
async addField(
|
||||
settingId: string,
|
||||
field: Partial<FileUploadField>,
|
||||
): Promise<FileUploadField> {
|
||||
const entity = this.fieldsRepository.create({ ...field, settingId });
|
||||
return this.fieldsRepository.save(entity);
|
||||
}
|
||||
|
||||
async updateField(
|
||||
fieldId: string,
|
||||
data: Partial<FileUploadField>,
|
||||
): Promise<FileUploadField | null> {
|
||||
await this.fieldsRepository.update(fieldId, data as never);
|
||||
return this.fieldsRepository.findOne({ where: { id: fieldId } });
|
||||
}
|
||||
|
||||
async removeField(fieldId: string): Promise<void> {
|
||||
await this.fieldsRepository.softDelete(fieldId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import {
|
||||
ConflictException,
|
||||
Inject,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
|
||||
import { CreateFileUploadFieldDto } from "./dto/create-file-upload-field.dto";
|
||||
import { CreateFileUploadSettingDto } from "./dto/create-file-upload-setting.dto";
|
||||
import { UpdateFileUploadFieldDto } from "./dto/update-file-upload-field.dto";
|
||||
import { UpdateFileUploadSettingDto } from "./dto/update-file-upload-setting.dto";
|
||||
import { FileUploadField } from "./entities/file-upload-field.entity";
|
||||
import { FileUploadSetting } from "./entities/file-upload-setting.entity";
|
||||
import {
|
||||
FILE_UPLOAD_SETTINGS_REPOSITORY,
|
||||
IFileUploadSettingsRepository,
|
||||
} from "./interfaces/file-upload-settings.repository.interface";
|
||||
|
||||
@Injectable()
|
||||
export class FileUploadSettingsService {
|
||||
constructor(
|
||||
@Inject(FILE_UPLOAD_SETTINGS_REPOSITORY)
|
||||
private readonly repository: IFileUploadSettingsRepository,
|
||||
) {}
|
||||
|
||||
list(): Promise<FileUploadSetting[]> {
|
||||
return this.repository.findAll();
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<FileUploadSetting> {
|
||||
const setting = await this.repository.findById(id);
|
||||
if (!setting) throw new NotFoundException(`Setting ${id} not found`);
|
||||
return setting;
|
||||
}
|
||||
|
||||
async getByCode(code: string): Promise<FileUploadSetting> {
|
||||
const setting = await this.repository.findByCode(code);
|
||||
if (!setting) throw new NotFoundException(`Setting "${code}" not found`);
|
||||
return setting;
|
||||
}
|
||||
|
||||
async create(dto: CreateFileUploadSettingDto): Promise<FileUploadSetting> {
|
||||
const existing = await this.repository.findByCode(dto.code);
|
||||
if (existing) {
|
||||
throw new ConflictException(
|
||||
`File upload setting with code "${dto.code}" already exists`,
|
||||
);
|
||||
}
|
||||
|
||||
const setting = await this.repository.create({
|
||||
code: dto.code,
|
||||
label: dto.label,
|
||||
description: dto.description ?? null,
|
||||
entity: dto.entity,
|
||||
});
|
||||
|
||||
if (dto.fields && dto.fields.length > 0) {
|
||||
await this.repository.replaceFields(setting.id, dto.fields);
|
||||
}
|
||||
|
||||
return this.getById(setting.id);
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
dto: UpdateFileUploadSettingDto,
|
||||
): Promise<FileUploadSetting> {
|
||||
await this.getById(id); // existence check
|
||||
const updated = await this.repository.update(id, dto);
|
||||
if (!updated) throw new NotFoundException(`Setting ${id} not found`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
await this.getById(id);
|
||||
await this.repository.softDelete(id);
|
||||
}
|
||||
|
||||
/* ------------------------ field operations ------------------------ */
|
||||
|
||||
async replaceFields(
|
||||
settingId: string,
|
||||
fields: CreateFileUploadFieldDto[],
|
||||
): Promise<FileUploadField[]> {
|
||||
await this.getById(settingId);
|
||||
return this.repository.replaceFields(settingId, fields);
|
||||
}
|
||||
|
||||
async addField(
|
||||
settingId: string,
|
||||
dto: CreateFileUploadFieldDto,
|
||||
): Promise<FileUploadField> {
|
||||
await this.getById(settingId);
|
||||
return this.repository.addField(settingId, dto);
|
||||
}
|
||||
|
||||
async updateField(
|
||||
fieldId: string,
|
||||
dto: UpdateFileUploadFieldDto,
|
||||
): Promise<FileUploadField> {
|
||||
const updated = await this.repository.updateField(fieldId, dto);
|
||||
if (!updated) throw new NotFoundException(`Field ${fieldId} not found`);
|
||||
return updated;
|
||||
}
|
||||
|
||||
async removeField(fieldId: string): Promise<void> {
|
||||
await this.repository.removeField(fieldId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { FileUploadField } from "../entities/file-upload-field.entity";
|
||||
import { FileUploadSetting } from "../entities/file-upload-setting.entity";
|
||||
|
||||
/**
|
||||
* Contract every FileUploadSettings repository must satisfy. Lets services
|
||||
* depend on the abstraction and lets tests swap in an in-memory fake.
|
||||
*/
|
||||
export const FILE_UPLOAD_SETTINGS_REPOSITORY = Symbol(
|
||||
"FILE_UPLOAD_SETTINGS_REPOSITORY",
|
||||
);
|
||||
|
||||
export interface IFileUploadSettingsRepository {
|
||||
findAll(): Promise<FileUploadSetting[]>;
|
||||
findById(id: string): Promise<FileUploadSetting | null>;
|
||||
findByCode(code: string): Promise<FileUploadSetting | null>;
|
||||
|
||||
create(data: Partial<FileUploadSetting>): Promise<FileUploadSetting>;
|
||||
update(
|
||||
id: string,
|
||||
data: Partial<FileUploadSetting>,
|
||||
): Promise<FileUploadSetting | null>;
|
||||
softDelete(id: string): Promise<void>;
|
||||
|
||||
/* Field-level helpers */
|
||||
replaceFields(
|
||||
settingId: string,
|
||||
fields: Array<Partial<FileUploadField>>,
|
||||
): Promise<FileUploadField[]>;
|
||||
addField(
|
||||
settingId: string,
|
||||
field: Partial<FileUploadField>,
|
||||
): Promise<FileUploadField>;
|
||||
updateField(
|
||||
fieldId: string,
|
||||
data: Partial<FileUploadField>,
|
||||
): Promise<FileUploadField | null>;
|
||||
removeField(fieldId: string): Promise<void>;
|
||||
}
|
||||
Reference in New Issue
Block a user