file upload settings

This commit is contained in:
yaschalew
2026-05-20 08:15:29 +03:00
parent 6aa8265bf5
commit 51d6df42bb
28 changed files with 2483 additions and 4 deletions

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
import { PartialType } from "@nestjs/swagger";
import { CreateFileUploadFieldDto } from "./create-file-upload-field.dto";
export class UpdateFileUploadFieldDto extends PartialType(
CreateFileUploadFieldDto,
) {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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