Преглед изворни кода

feat: add simulation template options endpoint

ChenYu пре 4 месеци
родитељ
комит
9f9f128623

+ 25 - 0
backend/src/modules/simulations/dto/list-simulation-templates-query.dto.ts

@@ -0,0 +1,25 @@
+import { Transform } from 'class-transformer';
+import { IsEnum, IsInt, IsOptional, IsString, Min } from 'class-validator';
+
+export class ListSimulationTemplateOptionsQueryDto {
+  @IsEnum(['STYLIST', 'SYSTEM'])
+  source!: 'STYLIST' | 'SYSTEM';
+
+  @IsOptional()
+  @IsEnum(['LONG', 'MEDIUM', 'SHORT', 'DYE'])
+  category?: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE';
+
+  @IsOptional()
+  @IsString()
+  q?: string;
+
+  @Transform(({ value }) => Number(value ?? 1))
+  @IsInt()
+  @Min(1)
+  page = 1;
+
+  @Transform(({ value }) => Number(value ?? 12))
+  @IsInt()
+  @Min(1)
+  pageSize = 12;
+}

+ 10 - 0
backend/src/modules/simulations/dto/simulation-template-option.dto.ts

@@ -0,0 +1,10 @@
+export type SimulationTemplateOptionDto = {
+  id: string;
+  templateName: string;
+  coverUrl: string;
+  category: 'LONG' | 'MEDIUM' | 'SHORT' | 'DYE';
+  tags: string[];
+  description: string | null;
+  templateUrl: string;
+  isSystemTemplate: boolean;
+};

+ 30 - 1
backend/src/modules/simulations/simulations.controller.ts

@@ -1,11 +1,40 @@
-import { Body, Controller, Post } from '@nestjs/common';
+import {
+  Body,
+  Controller,
+  Get,
+  Post,
+  Query,
+  Req,
+  UseGuards,
+} from '@nestjs/common';
+import type { Request } from 'express';
+import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
 import { CreateSimulationDto } from './dto/create-simulation.dto';
+import { ListSimulationTemplateOptionsQueryDto } from './dto/list-simulation-templates-query.dto';
 import { SimulationsService } from './simulations.service';
 
+type AuthenticatedRequest = Request & {
+  user: {
+    stylistId: string | number;
+  };
+};
+
+@UseGuards(JwtAuthGuard)
 @Controller('simulations')
 export class SimulationsController {
   constructor(private readonly simulationsService: SimulationsService) {}
 
+  @Get('template-options')
+  listTemplateOptions(
+    @Req() request: AuthenticatedRequest,
+    @Query() query: ListSimulationTemplateOptionsQueryDto,
+  ) {
+    return this.simulationsService.listTemplateOptions(
+      BigInt(request.user.stylistId),
+      query,
+    );
+  }
+
   @Post()
   create(@Body() body: CreateSimulationDto) {
     return this.simulationsService.create(body);

+ 3 - 0
backend/src/modules/simulations/simulations.module.ts

@@ -1,8 +1,11 @@
 import { Module } from '@nestjs/common';
+import { PrismaModule } from '../../prisma/prisma.module';
+import { AuthModule } from '../auth/auth.module';
 import { SimulationsController } from './simulations.controller';
 import { SimulationsService } from './simulations.service';
 
 @Module({
+  imports: [AuthModule, PrismaModule],
   controllers: [SimulationsController],
   providers: [SimulationsService],
 })

+ 98 - 0
backend/src/modules/simulations/simulations.service.spec.ts

@@ -0,0 +1,98 @@
+import { Test } from '@nestjs/testing';
+import { PrismaService } from '../../prisma/prisma.service';
+import { SimulationsService } from './simulations.service';
+
+describe('SimulationsService', () => {
+  let service: SimulationsService;
+  const prisma = {
+    hairTemplate: {
+      findMany: jest.fn(),
+      count: jest.fn(),
+    },
+  };
+
+  beforeEach(async () => {
+    jest.resetAllMocks();
+
+    const moduleRef = await Test.createTestingModule({
+      providers: [
+        SimulationsService,
+        { provide: PrismaService, useValue: prisma },
+      ],
+    }).compile();
+
+    service = moduleRef.get(SimulationsService);
+  });
+
+  it('returns only public stylist templates for the current stylist', async () => {
+    prisma.hairTemplate.findMany.mockResolvedValue([
+      {
+        id: BigInt(11),
+        templateName: 'Layer Bob',
+        coverUrl: 'https://cdn.example.com/bob.png',
+        category: 'SHORT',
+        tags: 'airy,bob',
+        description: 'short bob',
+        templateUrl: 'https://cdn.example.com/bob.zip',
+        isSystemTemplate: 'NO',
+      },
+    ]);
+    prisma.hairTemplate.count.mockResolvedValue(1);
+
+    const result = await service.listTemplateOptions(BigInt(7), {
+      source: 'STYLIST',
+      page: 1,
+      pageSize: 12,
+    });
+
+    expect(prisma.hairTemplate.findMany).toHaveBeenCalledWith(
+      expect.objectContaining({
+        where: expect.objectContaining({
+          isDeleted: 0,
+          status: 'PUBLIC',
+          isSystemTemplate: 'NO',
+          hairstylistId: BigInt(7),
+        }),
+      }),
+    );
+    expect(result.items[0]).toMatchObject({
+      id: '11',
+      templateName: 'Layer Bob',
+      tags: ['airy', 'bob'],
+      isSystemTemplate: false,
+    });
+  });
+
+  it('returns only public system templates for the system tab', async () => {
+    prisma.hairTemplate.findMany.mockResolvedValue([
+      {
+        id: BigInt(21),
+        templateName: 'Classic Medium',
+        coverUrl: '',
+        category: 'MEDIUM',
+        tags: '',
+        description: null,
+        templateUrl: 'https://cdn.example.com/medium.zip',
+        isSystemTemplate: 'YES',
+      },
+    ]);
+    prisma.hairTemplate.count.mockResolvedValue(1);
+
+    const result = await service.listTemplateOptions(BigInt(7), {
+      source: 'SYSTEM',
+      page: 1,
+      pageSize: 12,
+    });
+
+    expect(prisma.hairTemplate.findMany).toHaveBeenCalledWith(
+      expect.objectContaining({
+        where: expect.objectContaining({
+          isDeleted: 0,
+          status: 'PUBLIC',
+          isSystemTemplate: 'YES',
+        }),
+      }),
+    );
+    expect(result.items[0].id).toBe('21');
+  });
+});

+ 54 - 0
backend/src/modules/simulations/simulations.service.ts

@@ -1,8 +1,62 @@
 import { Injectable, NotImplementedException } from '@nestjs/common';
+import { PrismaService } from '../../prisma/prisma.service';
 import { CreateSimulationDto } from './dto/create-simulation.dto';
+import { ListSimulationTemplateOptionsQueryDto } from './dto/list-simulation-templates-query.dto';
+import { SimulationTemplateOptionDto } from './dto/simulation-template-option.dto';
 
 @Injectable()
 export class SimulationsService {
+  constructor(private readonly prisma: PrismaService) {}
+
+  async listTemplateOptions(
+    hairstylistId: bigint,
+    query: ListSimulationTemplateOptionsQueryDto,
+  ): Promise<{
+    items: SimulationTemplateOptionDto[];
+    total: number;
+    page: number;
+    pageSize: number;
+  }> {
+    const where = {
+      isDeleted: 0,
+      status: 'PUBLIC' as const,
+      templateUrl: { not: '' },
+      ...(query.category ? { category: query.category } : {}),
+      ...(query.q ? { templateName: { contains: query.q } } : {}),
+      ...(query.source === 'STYLIST'
+        ? { isSystemTemplate: 'NO' as const, hairstylistId }
+        : { isSystemTemplate: 'YES' as const }),
+    };
+
+    const [items, total] = await Promise.all([
+      this.prisma.hairTemplate.findMany({
+        where,
+        orderBy: { id: 'desc' },
+        skip: (query.page - 1) * query.pageSize,
+        take: query.pageSize,
+      }),
+      this.prisma.hairTemplate.count({ where }),
+    ]);
+
+    return {
+      items: items.map((item) => ({
+        id: item.id.toString(),
+        templateName: item.templateName,
+        coverUrl: item.coverUrl,
+        category: item.category,
+        tags: item.tags
+          ? item.tags.split(',').map((tag) => tag.trim()).filter(Boolean)
+          : [],
+        description: item.description,
+        templateUrl: item.templateUrl,
+        isSystemTemplate: item.isSystemTemplate === 'YES',
+      })),
+      total,
+      page: query.page,
+      pageSize: query.pageSize,
+    };
+  }
+
   async create(_input: CreateSimulationDto): Promise<never> {
     throw new NotImplementedException(
       'Simulation creation is not implemented yet',