Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/app/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ import { ScanResourceAttachments1755504936756 } from 'omniboxd/migrations/175550
import { SharesAllResources1754471311959 } from 'omniboxd/migrations/1754471311959-shares-all-resources';
import { ResourcesModule } from 'omniboxd/resources/resources.module';
import { TraceModule } from 'omniboxd/trace/trace.module';
import { FeedbackModule } from 'omniboxd/feedback/feedback.module';
import { Feedback1757100000000 } from 'omniboxd/migrations/1757100000000-feedback';
import { UserInterceptor } from 'omniboxd/interceptor/user.interceptor';

@Module({})
Expand Down Expand Up @@ -104,6 +106,7 @@ export class AppModule implements NestModule {
SharesModule,
SharedResourcesModule,
TraceModule,
FeedbackModule,
// CacheModule.registerAsync({
// imports: [ConfigModule],
// inject: [ConfigService],
Expand Down Expand Up @@ -138,6 +141,7 @@ export class AppModule implements NestModule {
UpdateAttachmentUrls1755499552000,
ScanResourceAttachments1755504936756,
SharesAllResources1754471311959,
Feedback1757100000000,
...extraMigrations,
],
migrationsRun: true,
Expand Down
24 changes: 24 additions & 0 deletions src/feedback/dto/create-feedback.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {
IsEnum,
IsString,
IsOptional,
IsNotEmpty,
MaxLength,
} from 'class-validator';
import { FeedbackType } from '../entities/feedback.entity';

export class CreateFeedbackDto {
@IsEnum(FeedbackType)
@IsNotEmpty()
type: FeedbackType;

@IsString()
@IsNotEmpty()
@MaxLength(5000)
description: string;

@IsString()
@IsOptional()
@MaxLength(500)
contactInfo?: string;
}
36 changes: 36 additions & 0 deletions src/feedback/entities/feedback.entity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Base } from 'omniboxd/common/base.entity';
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';

export enum FeedbackType {
BUG = 'bug',
SUGGESTION = 'suggestion',
FEATURE = 'feature',
OTHER = 'other',
}

@Entity('feedback')
export class Feedback extends Base {
@PrimaryGeneratedColumn()
id: number;

@Column({
type: 'enum',
enum: FeedbackType,
})
type: FeedbackType;

@Column('text')
description: string;

@Column('varchar', { nullable: true, name: 'image_url' })
imageUrl: string | null;

@Column('varchar', { nullable: true, name: 'contact_info' })
contactInfo: string | null;

@Column('text', { nullable: true, name: 'user_agent' })
userAgent: string | null;

@Column('uuid', { nullable: true, name: 'user_id' })
userId: string | null;
}
70 changes: 70 additions & 0 deletions src/feedback/feedback.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import {
Req,
Post,
Body,
Controller,
UploadedFile,
UseInterceptors,
BadRequestException,
} from '@nestjs/common';
import { encodeFileName } from 'omniboxd/utils/encode-filename';
import { FileInterceptor } from '@nestjs/platform-express';
import { Request } from 'express';
import { FeedbackService } from './feedback.service';
import { CreateFeedbackDto } from './dto/create-feedback.dto';
import { Public } from 'omniboxd/auth/decorators/public.auth.decorator';
import { UserId } from 'omniboxd/decorators/user-id.decorator';
import { MinioService } from 'omniboxd/minio/minio.service';

@Controller('api/v1/feedback')
export class FeedbackController {
constructor(
private readonly feedbackService: FeedbackService,
private readonly minioService: MinioService,
) {}

@Public()
@Post()
@UseInterceptors(
FileInterceptor('image', {
limits: {
fileSize: 5 * 1024 * 1024,
},
fileFilter: (req, file, cb) => {
if (file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
cb(new BadRequestException('Only image files are allowed'), false);
}
},
}),
)
async createFeedback(
@Body() createFeedbackDto: CreateFeedbackDto,
@UploadedFile() image: Express.Multer.File,
@Req() request: Request,
@UserId({ optional: true }) userId?: string,
) {
let imageUrl = '';

if (image) {
const originalname = encodeFileName(image.originalname);
const uploadResult = await this.minioService.put(
originalname,
image.buffer,
image.mimetype,
{ folder: 'feedback' },
);
imageUrl = uploadResult.id;
}

const userAgent = request.get('User-Agent');

return this.feedbackService.createFeedback(
createFeedbackDto,
imageUrl,
userAgent,
userId,
);
}
}
14 changes: 14 additions & 0 deletions src/feedback/feedback.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FeedbackController } from './feedback.controller';
import { FeedbackService } from './feedback.service';
import { Feedback } from './entities/feedback.entity';
import { MinioModule } from 'omniboxd/minio/minio.module';

@Module({
imports: [MinioModule, TypeOrmModule.forFeature([Feedback])],
controllers: [FeedbackController],
providers: [FeedbackService],
exports: [FeedbackService],
})
export class FeedbackModule {}
68 changes: 68 additions & 0 deletions src/feedback/feedback.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { FeedbackService } from './feedback.service';
import { Feedback, FeedbackType } from './entities/feedback.entity';
import { CreateFeedbackDto } from './dto/create-feedback.dto';

const mockFeedbackRepository = {
create: jest.fn(),
save: jest.fn(),
};

describe('FeedbackService', () => {
let service: FeedbackService;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
FeedbackService,
{
provide: getRepositoryToken(Feedback),
useValue: mockFeedbackRepository,
},
],
}).compile();

service = module.get<FeedbackService>(FeedbackService);
});

it('should be defined', () => {
expect(service).toBeDefined();
});

describe('createFeedback', () => {
it('should create feedback successfully', async () => {
const createFeedbackDto: CreateFeedbackDto = {
type: FeedbackType.BUG,
description: 'Test bug report',
contactInfo: 'test@example.com',
};

const mockFeedback = {
id: '1',
...createFeedbackDto,
imageUrl: null,
userAgent: 'Mozilla/5.0',
userId: null,
user: null,
createdAt: new Date(),
updatedAt: new Date(),
};

mockFeedbackRepository.create.mockReturnValue(mockFeedback);
mockFeedbackRepository.save.mockResolvedValue(mockFeedback);

await service.createFeedback(createFeedbackDto, undefined, 'Mozilla/5.0');

expect(mockFeedbackRepository.create).toHaveBeenCalledWith({
type: createFeedbackDto.type,
description: createFeedbackDto.description,
contactInfo: createFeedbackDto.contactInfo,
imageUrl: undefined,
userAgent: 'Mozilla/5.0',
userId: undefined,
});
expect(mockFeedbackRepository.save).toHaveBeenCalledWith(mockFeedback);
});
});
});
31 changes: 31 additions & 0 deletions src/feedback/feedback.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Feedback } from './entities/feedback.entity';
import { CreateFeedbackDto } from './dto/create-feedback.dto';

@Injectable()
export class FeedbackService {
constructor(
@InjectRepository(Feedback)
private readonly feedbackRepository: Repository<Feedback>,
) {}

async createFeedback(
createFeedbackDto: CreateFeedbackDto,
imageUrl?: string,
userAgent?: string,
userId?: string,
) {
const feedback = this.feedbackRepository.create({
type: createFeedbackDto.type,
description: createFeedbackDto.description,
contactInfo: createFeedbackDto.contactInfo,
imageUrl,
userAgent,
userId,
});

await this.feedbackRepository.save(feedback);
}
}
89 changes: 89 additions & 0 deletions src/migrations/1757100000000-feedback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { MigrationInterface, QueryRunner, Table } from 'typeorm';
import { BaseColumns } from './base-columns';

async function createFeedbackTypeEnum(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TYPE feedback_type_enum AS ENUM (
'bug',
'suggestion',
'feature',
'other'
)
`);
}

async function createFeedbackTable(queryRunner: QueryRunner): Promise<void> {
const table = new Table({
name: 'feedback',
columns: [
{
name: 'id',
type: 'bigserial',
isPrimary: true,
},
{
name: 'type',
type: 'feedback_type_enum',
isNullable: false,
},
{
name: 'description',
type: 'text',
isNullable: false,
},
{
name: 'image_url',
type: 'character varying',
isNullable: true,
},
{
name: 'contact_info',
type: 'character varying',
isNullable: true,
},
{
name: 'user_agent',
type: 'text',
isNullable: true,
},
{
name: 'user_id',
type: 'uuid',
isNullable: true,
},
...BaseColumns(),
],
foreignKeys: [
{
columnNames: ['user_id'],
referencedTableName: 'users',
referencedColumnNames: ['id'],
onDelete: 'SET NULL',
},
],
indices: [
{
columnNames: ['type'],
},
{
columnNames: ['user_id'],
},
{
columnNames: ['created_at'],
},
],
});
await queryRunner.createTable(table, true, true, true);
}

export class Feedback1757100000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await createFeedbackTypeEnum(queryRunner);
await createFeedbackTable(queryRunner);
}

// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/require-await
public async down(_queryRunner: QueryRunner): Promise<void> {
throw new Error('Not supported.');
}
}