Skip to content

Commit

Permalink
admin/backend: Actual CSV parsing in backend for preview
Browse files Browse the repository at this point in the history
  • Loading branch information
somnisomni committed Jun 5, 2024
1 parent 911ab85 commit a5a28e4
Show file tree
Hide file tree
Showing 14 changed files with 141 additions and 24 deletions.
20 changes: 10 additions & 10 deletions .pnp.cjs

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file not shown.
Binary file not shown.
4 changes: 2 additions & 2 deletions packages/Common/src/data/goods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ export const goodsCsvHeader = [
"type",
"price",
"stock_initial",
];
] as const;

export const goodsCsvHeaderStringified = goodsCsvHeader.join(",");

export const goodsCsvHeaderMap = Object.fromEntries(goodsCsvHeader.map((value) => [value, value]));
export const goodsCsvHeaderMap = Object.freeze(Object.fromEntries(goodsCsvHeader.map((value) => [value, value])));
8 changes: 8 additions & 0 deletions packages/Common/src/interfaces/goods.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* eslint-disable import/exports-last */

import { IImageUploadInfo } from "./base";
import { IGoodsCategory } from "./goods-category";

/* === Common === */
export interface IGoodsCommon {
Expand Down Expand Up @@ -58,7 +59,14 @@ export interface IGoodsCreateRequest extends Omit<IGoodsBase, "id" | "combinatio
stockRemaining: number;
}
export interface IGoodsUpdateRequest extends Partial<Omit<IGoodsCreateRequest, "boothId">>, Pick<IGoodsCreateRequest, "boothId"> { }
export interface IGoodsCSVImportRequest {
csv: string;
}

/* === Responses === */
export interface IGoodsResponse extends IGoods { }
export interface IGoodsAdminResponse extends IGoodsAdmin { }
export interface IGoodsCSVImportPreviewResponse {
goods: Array<Pick<IGoodsAdmin, "name" | "categoryId" | "description" | "type" | "price" | "stock">>;
categories: Array<Pick<IGoodsCategory, "name">>;
}
30 changes: 27 additions & 3 deletions projects/Admin/src/components/dialogs/GoodsImportDialog.vue
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<template>
<CommonDialog v-model="open"
:progressActive="requestingPreview"
dialogTitle="굿즈 목록 가져오기"
dialogCancelText="취소"
dialogPrimaryText="미리보기"
Expand All @@ -15,6 +16,7 @@
<a href="https://www.microsoft.com/microsoft-365/excel" target="_blank" style="color: currentColor">Microsoft Excel</a>,
<a href="https://docs.google.com/spreadsheets/" target="_blank" style="color: currentColor">Google Sheets</a>
등 스프레드시트 프로그램으로 편집할 수 있습니다.</p>
<p class="text-center text-info text-subtitle-2">CSV 파일을 저장할 때, <strong>반드시 UTF-8 인코딩으로 저장</strong>해주세요.</p>

<p class="mt-6">파일 업로드: <FileInputButton v-model="selectedFile" acceptsCustom="text/csv" /></p>
<VExpandTransition>
Expand All @@ -29,6 +31,7 @@
<script lang="ts">
import { goodsCsvHeaderStringified } from "@myboothmanager/common";
import { Component, Model, Vue, Watch } from "vue-facing-decorator";
import { useAdminAPIStore } from "@/plugins/stores/api";
import FileInputButton from "../common/FileInputButton.vue";
@Component({
Expand All @@ -39,6 +42,7 @@ import FileInputButton from "../common/FileInputButton.vue";
export default class GoodsImportDialog extends Vue {
@Model({ type: Boolean }) declare open: boolean;
requestingPreview = false;
selectedFile: File | null = null;
csvText: string = "";
Expand All @@ -64,12 +68,32 @@ export default class GoodsImportDialog extends Vue {
reader.addEventListener("load", () => {
console.log(reader.result);
});
reader.readAsText(this.selectedFile);
reader.readAsText(this.selectedFile, "UTF-8");
}
}
onDialogPrimary() {
alert(this.csvText);
async onDialogPrimary() {
this.requestingPreview = true;
let csv: string = this.csvText;
if(this.selectedFile) {
let done = false;
const reader = new FileReader();
reader.addEventListener("load", () => {
csv = reader.result as string;
done = true;
});
reader.readAsText(this.selectedFile, "UTF-8");
while(!done) {
await new Promise(resolve => setTimeout(resolve, 100));
}
}
const response = await useAdminAPIStore().requestPreviewGoodsCSVImport(csv);
console.log(response);
this.requestingPreview = false;
}
downloadTemplate() {
Expand Down
5 changes: 5 additions & 0 deletions projects/Admin/src/lib/api-admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,4 +206,9 @@ export default class AdminAPI extends BaseAdminAPI {
static async deleteGoodsCombinationImage(combinationId: number, boothId: number) {
return await this.apiCallWrapper<CT.ISuccessResponse>(() => this.API.DELETE(`goods/combination/${combinationId}/image?bId=${boothId}`));
}

/* Utility */
static async requestPreviewGoodsCSVImport(csv: string) {
return await this.apiCallWrapper<CT.IGoodsCSVImportPreviewResponse>(() => this.API.POST("goods/csv/preview", { csv } as CT.IGoodsCSVImportRequest));
}
}
12 changes: 12 additions & 0 deletions projects/Admin/src/plugins/stores/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,17 @@ const useAdminAPIStore = defineStore("admin-api", () => {
);
}

async function requestPreviewGoodsCSVImport(csv: string): Promise<C.IGoodsCSVImportPreviewResponse | C.ErrorCodes> {
let apiResponse: C.IGoodsCSVImportPreviewResponse | null = null;
const errorCode = await simplifyAPICall(
() => AdminAPI.requestPreviewGoodsCSVImport(csv),
(res) => apiResponse = res,
);

if(typeof errorCode === "number") return errorCode;
return apiResponse ?? C.ErrorCodes.UNKNOWN_ERROR;
}

/* Goods Combination */
async function fetchGoodsCombinationsOfCurrentBooth(): Promise<true | C.ErrorCodes> {
return await simplifyAPICall(
Expand Down Expand Up @@ -446,6 +457,7 @@ const useAdminAPIStore = defineStore("admin-api", () => {
uploadGoodsImage,
deleteGoodsImage,
deleteGoods,
requestPreviewGoodsCSVImport,

fetchGoodsCombinationsOfCurrentBooth,
createGoodsCombination,
Expand Down
18 changes: 18 additions & 0 deletions projects/Backend/src/modules/admin/goods/dto/import-goods.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Exclude, Expose } from "class-transformer";
import { IGoodsCSVImportPreviewResponse, IGoodsCSVImportRequest } from "@myboothmanager/common";

export class GoodsImportRequestDto implements IGoodsCSVImportRequest {
declare csv: string;
}

@Exclude()
export class GoodsImportPreviewResponseDto implements IGoodsCSVImportPreviewResponse {
@Expose() declare goods: IGoodsCSVImportPreviewResponse["goods"];
@Expose() declare categories: IGoodsCSVImportPreviewResponse["categories"];

constructor(goods: IGoodsCSVImportPreviewResponse["goods"],
categories: IGoodsCSVImportPreviewResponse["categories"]) {
this.goods = goods;
this.categories = categories;
}
}
9 changes: 9 additions & 0 deletions projects/Backend/src/modules/admin/goods/goods.controller.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import type { FastifyRequest } from "fastify";
import { Controller, Get, Post, Body, Patch, Param, Delete, Query, Req, UseGuards, UseInterceptors, ClassSerializerInterceptor } from "@nestjs/common";
import { PublicGoodsService } from "@/modules/public/goods/goods.service";
import { InvalidRequestBodyException } from "@/lib/exceptions";
import { AuthData, AdminAuthGuard, SuperAdmin } from "../auth/auth.guard";
import { IAuthPayload } from "../auth/jwt";
import { UtilService } from "../util/util.service";
import { GoodsService } from "./goods.service";
import { CreateGoodsRequestDto } from "./dto/create-goods.dto";
import { UpdateGoodsRequestDto } from "./dto/update-goods.dto";
import { AdminGoodsResponseDto } from "./dto/goods.dto";
import { GoodsImportPreviewResponseDto } from "./dto/import-goods.dto";

@UseGuards(AdminAuthGuard)
@Controller("/admin/goods")
Expand Down Expand Up @@ -45,6 +47,13 @@ export class GoodsController {
return await this.goodsService.remove(+id, +boothId, authData.id);
}

@Post("csv/preview")
async previewCSVImport(@Body("csv") csv: string): Promise<GoodsImportPreviewResponseDto> {
if(!csv || csv.length <= 0) throw new InvalidRequestBodyException();

return await this.goodsService.previewCSVImport(csv);
}

/* SuperAdmin routes */
@SuperAdmin()
@UseInterceptors(ClassSerializerInterceptor)
Expand Down
43 changes: 41 additions & 2 deletions projects/Backend/src/modules/admin/goods/goods.service.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import type { MultipartFile } from "@fastify/multipart";
import { Injectable } from "@nestjs/common";
import { ISuccessResponse, ISingleValueResponse, ImageSizeConstraintKey, IImageUploadInfo } from "@myboothmanager/common";
import { ISuccessResponse, ISingleValueResponse, ImageSizeConstraintKey, IImageUploadInfo, GoodsStockVisibility } from "@myboothmanager/common";
import Goods from "@/db/models/goods";
import Booth from "@/db/models/booth";
import { create, findOneByPk, removeTarget } from "@/lib/common-functions";
import { EntityNotFoundException, NoAccessException } from "@/lib/exceptions";
import { UtilService } from "../util/util.service";
import { CSVService } from "../util/csv.service";
import { UpdateGoodsRequestDto } from "./dto/update-goods.dto";
import { CreateGoodsRequestDto } from "./dto/create-goods.dto";
import { GoodsInfoUpdateFailedException, GoodsParentBoothNotFoundException } from "./goods.exception";
import { GoodsImportPreviewResponseDto } from "./dto/import-goods.dto";

@Injectable()
export class GoodsService {
constructor(private readonly utilService: UtilService) { }
constructor(
private readonly utilService: UtilService,
private readonly csvService: CSVService,
) { }

private async getGoodsAndParentBooth(goodsId: number, boothId: number, callerAccountId: number): Promise<{ goods: Goods, booth: Booth }> {
const goods = await findOneByPk(Goods, goodsId);
Expand Down Expand Up @@ -108,4 +113,38 @@ export class GoodsService {

return await removeTarget(goods);
}

async previewCSVImport(csv: string): Promise<GoodsImportPreviewResponseDto> {
const parsed = await this.csvService.parseCSVString(csv/*, goodsCsvHeader as unknown as Array<string>*/);
console.log(parsed);

// Extract categories first
const categories: GoodsImportPreviewResponseDto["categories"] = [];
for(const record of parsed) {
const category: string = record["category_name"];
if(category && categories.findIndex((c) => c.name === category) < 0) {
categories.push({ name: category });
}
}

// Then goods
const goods: GoodsImportPreviewResponseDto["goods"] = [];
for(const record of parsed) {
const categoryId = categories.findIndex((c) => c.name === record["category_name"]);

goods.push({
categoryId: categoryId >= 0 ? categoryId : undefined,
name: record["name"],
description: record["description"],
price: record["price"],
stock: {
initial: record["stock_initial"],
remaining: record["stock_initial"],
visibility: GoodsStockVisibility.SHOW_ALL,
},
});
}

return new GoodsImportPreviewResponseDto(goods, categories);
}
}
3 changes: 2 additions & 1 deletion projects/Backend/src/modules/admin/util/csv.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import * as csv from "csv-parse";
export class CSVService {
constructor() { }

async parseCSVString(csvStr: string, columns: Array<string> | true = true): Promise<Array<Record<string, unknown>>> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async parseCSVString<T extends Array<string>>(csvStr: string, columns: T | true = true): Promise<Array<Record<string, any>>> {
const normalized = csvStr.trim();

const records = [];
Expand Down
5 changes: 3 additions & 2 deletions projects/Backend/src/modules/admin/util/util.module.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Module } from "@nestjs/common";
import { UtilService } from "./util.service";
import { CSVService } from "./csv.service";

@Module({
providers: [UtilService],
exports: [UtilService],
providers: [UtilService, CSVService],
exports: [UtilService, CSVService],
})
export class UtilModule { }
8 changes: 4 additions & 4 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1629,19 +1629,19 @@ __metadata:

"@myboothmanager/common@file:../../packages/Common::locator=%40myboothmanager%2Fadmin%40workspace%3Aprojects%2FAdmin":
version: 0.5.0
resolution: "@myboothmanager/common@file:../../packages/Common#../../packages/Common::hash=959e2e&locator=%40myboothmanager%2Fadmin%40workspace%3Aprojects%2FAdmin"
resolution: "@myboothmanager/common@file:../../packages/Common#../../packages/Common::hash=1dfcab&locator=%40myboothmanager%2Fadmin%40workspace%3Aprojects%2FAdmin"
dependencies:
currency-symbol-map: "npm:^5.1.0"
checksum: 10/46f42b667675921b8815d3f6b78d719c008e9a3457eabd8b6a494900c0e1774b60367fe8cf8a596000a1d84d062513dce36f735f828a7ee6b7fb3eb23bf9db14
checksum: 10/e6ab0e054c3e3f2898584b2edd42e819390b3a845fa1a32e4b4e863f542d187cab6c7c8c4b0470f501dd61e7f7bec5c78b93e8966f556c29a5fd3470c7b36533
languageName: node
linkType: hard

"@myboothmanager/common@file:../../packages/Common::locator=%40myboothmanager%2Fpublic%40workspace%3Aprojects%2FPublic":
version: 0.5.0
resolution: "@myboothmanager/common@file:../../packages/Common#../../packages/Common::hash=959e2e&locator=%40myboothmanager%2Fpublic%40workspace%3Aprojects%2FPublic"
resolution: "@myboothmanager/common@file:../../packages/Common#../../packages/Common::hash=1dfcab&locator=%40myboothmanager%2Fpublic%40workspace%3Aprojects%2FPublic"
dependencies:
currency-symbol-map: "npm:^5.1.0"
checksum: 10/46f42b667675921b8815d3f6b78d719c008e9a3457eabd8b6a494900c0e1774b60367fe8cf8a596000a1d84d062513dce36f735f828a7ee6b7fb3eb23bf9db14
checksum: 10/e6ab0e054c3e3f2898584b2edd42e819390b3a845fa1a32e4b4e863f542d187cab6c7c8c4b0470f501dd61e7f7bec5c78b93e8966f556c29a5fd3470c7b36533
languageName: node
linkType: hard

Expand Down

0 comments on commit a5a28e4

Please sign in to comment.