From 921be97efc07f3c0687a54a865db555e9f0160b2 Mon Sep 17 00:00:00 2001 From: Gerbera3090 Date: Sat, 30 Nov 2024 04:11:41 +0900 Subject: [PATCH 01/10] fix: fix and add schemas for proposals and reviews --- .../api/src/drizzle/schema/enum.schema.ts | 14 +++ .../src/drizzle/schema/organization.schema.ts | 5 + .../api/src/drizzle/schema/proposal.schema.ts | 110 ++++++++++++++++++ 3 files changed, 129 insertions(+) diff --git a/packages/api/src/drizzle/schema/enum.schema.ts b/packages/api/src/drizzle/schema/enum.schema.ts index 203f39e..c51ac5c 100644 --- a/packages/api/src/drizzle/schema/enum.schema.ts +++ b/packages/api/src/drizzle/schema/enum.schema.ts @@ -93,6 +93,17 @@ export const AgendaAcceptedStatusEnum = mysqlTable( }, ); +export const DocumentReviewStatusEnum = mysqlTable( + "document_review_status_enum", + { + id: int("id").autoincrement().primaryKey().notNull(), + name: varchar("name", { length: 30 }).notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), + deletedAt: timestamp("deleted_at"), + }, +); + export type OrganizationTypeEnumT = InferSelectModel< typeof OrganizationTypeEnum >; @@ -102,3 +113,6 @@ export type OrganizationPresidentTypeEnumT = InferSelectModel< export type AgendaAcceptedStatusEnumT = InferSelectModel< typeof AgendaAcceptedStatusEnum >; +export type DocumentReviewStatusEnumT = InferSelectModel< + typeof DocumentReviewStatusEnum +>; diff --git a/packages/api/src/drizzle/schema/organization.schema.ts b/packages/api/src/drizzle/schema/organization.schema.ts index a668ae9..51118e5 100644 --- a/packages/api/src/drizzle/schema/organization.schema.ts +++ b/packages/api/src/drizzle/schema/organization.schema.ts @@ -99,6 +99,7 @@ export const OperatingCommitteeMember = mysqlTable( id: int("id").autoincrement().primaryKey().notNull(), organizationId: int("organization_id").notNull(), userId: int("user_id").notNull(), + semesterId: int("semester_id").notNull(), role: varchar("role", { length: 30 }).notNull(), legalBasis: varchar("legal_basis", { length: 30 }).notNull(), createdAt: timestamp("created_at").defaultNow().notNull(), @@ -115,6 +116,10 @@ export const OperatingCommitteeMember = mysqlTable( columns: [table.userId], foreignColumns: [User.id], }), + semesterIdFk: foreignKey({ + columns: [table.semesterId], + foreignColumns: [Semester.id], + }), }), ); diff --git a/packages/api/src/drizzle/schema/proposal.schema.ts b/packages/api/src/drizzle/schema/proposal.schema.ts index 68e15b2..2d89f5d 100644 --- a/packages/api/src/drizzle/schema/proposal.schema.ts +++ b/packages/api/src/drizzle/schema/proposal.schema.ts @@ -18,6 +18,7 @@ import { BudgetDivisionIncomeEnum, BudgetDivisionExpenseEnum, BudgetClassExpenseEnum, + DocumentReviewStatusEnum, } from "./enum.schema"; // ProjectProposal 테이블 @@ -62,6 +63,7 @@ export const ProjectProposalRevision = mysqlTable( target: text("target").notNull(), detail: text("detail").notNull(), note: text("note"), + submittedAt: timestamp("submitted_at"), agendaId: int("agenda_id"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), @@ -205,6 +207,7 @@ export const BudgetProposalIncomeRevision = mysqlTable( updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), deletedAt: timestamp("deleted_at"), agendaId: int("agenda_id"), + submittedAt: timestamp("submitted_at"), }, table => ({ documentIdFk: foreignKey({ @@ -272,6 +275,7 @@ export const BudgetProposalExpenseRevision = mysqlTable( updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), deletedAt: timestamp("deleted_at"), agendaId: int("agenda_id"), + submittedAt: timestamp("submitted_at"), }, table => ({ documentIdFk: foreignKey({ @@ -307,6 +311,103 @@ export const BudgetProposalExpenseRevision = mysqlTable( }), ); +// ProjectProposalDocumentReview 테이블 +export const ProjectProposalDocumentReview = mysqlTable( + "project_proposal_document_review", + { + id: int("id").autoincrement().primaryKey().notNull(), + documentId: int("document_id").notNull(), + userId: int("user_id").notNull(), + documentReviewStatusEnumId: int("document_review_status_enum_id").notNull(), + detail: text("detail"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), + deletedAt: timestamp("deleted_at"), + }, + table => ({ + documentIdFk: foreignKey({ + columns: [table.documentId], + foreignColumns: [ProjectProposal.id], + name: "pro_pro_doc_rev_document_id_fk", + }), + userIdFk: foreignKey({ + columns: [table.userId], + foreignColumns: [User.id], + name: "pro_pro_doc_rev_user_id_fk", + }), + documentReviewStatusEnumIdFk: foreignKey({ + columns: [table.documentReviewStatusEnumId], + foreignColumns: [DocumentReviewStatusEnum.id], + name: "pro_pro_doc_rev_document_review_status_enum_id_fk", + }), + }), +); + +// BudgetProposalIncomeDocumentReview 테이블 +export const BudgetProposalIncomeDocumentReview = mysqlTable( + "budget_proposal_income_document_review", + { + id: int("id").autoincrement().primaryKey().notNull(), + documentId: int("document_id").notNull(), + userId: int("user_id").notNull(), + documentReviewStatusEnumId: int("document_review_status_enum_id").notNull(), + detail: text("detail"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), + deletedAt: timestamp("deleted_at"), + }, + table => ({ + documentIdFk: foreignKey({ + columns: [table.documentId], + foreignColumns: [BudgetProposalIncome.id], + name: "bud_pro_inc_doc_rev_document_id_fk", + }), + userIdFk: foreignKey({ + columns: [table.userId], + foreignColumns: [User.id], + name: "bud_pro_inc_doc_rev_user_id_fk", + }), + documentReviewStatusEnumIdFk: foreignKey({ + columns: [table.documentReviewStatusEnumId], + foreignColumns: [DocumentReviewStatusEnum.id], + name: "bud_pro_inc_doc_rev_document_review_status_enum_id_fk", + }), + }), +); + +// BudgetProposalExpenseDocumentReview 테이블 +export const BudgetProposalExpenseDocumentReview = mysqlTable( + "budget_proposal_expense_document_review", + { + id: int("id").autoincrement().primaryKey().notNull(), + documentId: int("document_id").notNull(), + userId: int("user_id").notNull(), + documentReviewStatusEnumId: int("document_review_status_enum_id").notNull(), + detail: text("detail"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().onUpdateNow().notNull(), + deletedAt: timestamp("deleted_at"), + }, + table => ({ + documentIdFk: foreignKey({ + columns: [table.documentId], + foreignColumns: [BudgetProposalExpense.id], + name: "bud_pro_exp_doc_rev_document_id_fk", + }), + userIdFk: foreignKey({ + columns: [table.userId], + foreignColumns: [User.id], + name: "bud_pro_exp_doc_rev_user_id_fk", + }), + documentReviewStatusEnumIdFk: foreignKey({ + columns: [table.documentReviewStatusEnumId], + foreignColumns: [DocumentReviewStatusEnum.id], + name: "bud_pro_exp_doc_rev_document_review_status_enum_id_fk", + }), + }), +); + +// 타입 추론 export type ProjectProposalT = InferSelectModel; export type ProjectProposalRevisionT = InferSelectModel< typeof ProjectProposalRevision @@ -330,3 +431,12 @@ export type BudgetProposalExpenseT = InferSelectModel< export type BudgetProposalExpenseRevisionT = InferSelectModel< typeof BudgetProposalExpenseRevision >; +export type ProjectProposalDocumentReviewT = InferSelectModel< + typeof ProjectProposalDocumentReview +>; +export type BudgetProposalIncomeDocumentReviewT = InferSelectModel< + typeof BudgetProposalIncomeDocumentReview +>; +export type BudgetProposalExpenseDocumentReviewT = InferSelectModel< + typeof BudgetProposalExpenseDocumentReview +>; From 8527538e1132b12fb6fb8f04b8cd82f2e9b9b179 Mon Sep 17 00:00:00 2001 From: Gerbera3090 Date: Sat, 30 Nov 2024 04:32:20 +0900 Subject: [PATCH 02/10] feat: add new native enum --- .../interface/src/common/enum/meeting.enum.ts | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/packages/interface/src/common/enum/meeting.enum.ts b/packages/interface/src/common/enum/meeting.enum.ts index dfaf44c..3feb33d 100644 --- a/packages/interface/src/common/enum/meeting.enum.ts +++ b/packages/interface/src/common/enum/meeting.enum.ts @@ -10,10 +10,17 @@ export enum AssistantPermissionE { // 안건 상태 E export enum AgendaAcceptedStatusE { Accepted = 1, // 승인 - Reject, // 반려 - Revision, // 수정요청 + Rejected, // 반려 + ReviseNeeded, // 수정요청 + Progress, // 검토중 + LateAccepted, // 사후승인 +} + +// Document Review Status E +export enum DocumentReviewStatusE { + Accepted = 1, // 승인 + Rejected, // 반려 Progress, // 검토중 - Post, // 사후승인 } // AssistantPermissionE @@ -43,13 +50,13 @@ export const getDisplayNameAgendaAcceptedStatusE = ( switch (type) { case AgendaAcceptedStatusE.Accepted: return "승인"; - case AgendaAcceptedStatusE.Reject: + case AgendaAcceptedStatusE.Rejected: return "반려"; - case AgendaAcceptedStatusE.Revision: + case AgendaAcceptedStatusE.ReviseNeeded: return "수정요청"; case AgendaAcceptedStatusE.Progress: return "검토중"; - case AgendaAcceptedStatusE.Post: + case AgendaAcceptedStatusE.LateAccepted: return "사후승인"; default: return ""; From 53d23bcb3d8f36398e8edd098259dac4af8b302b Mon Sep 17 00:00:00 2001 From: Gerbera3090 Date: Sat, 30 Nov 2024 04:42:37 +0900 Subject: [PATCH 03/10] fix: not null conditions in revision table --- .../api/src/drizzle/schema/proposal.schema.ts | 22 +++---- .../src/api/proposal/endpoint/apiPrp004.ts | 59 +++++++++++++++++++ 2 files changed, 70 insertions(+), 11 deletions(-) create mode 100644 packages/interface/src/api/proposal/endpoint/apiPrp004.ts diff --git a/packages/api/src/drizzle/schema/proposal.schema.ts b/packages/api/src/drizzle/schema/proposal.schema.ts index 2d89f5d..700dff6 100644 --- a/packages/api/src/drizzle/schema/proposal.schema.ts +++ b/packages/api/src/drizzle/schema/proposal.schema.ts @@ -52,16 +52,16 @@ export const ProjectProposalRevision = mysqlTable( id: int("id").autoincrement().primaryKey().notNull(), documentId: int("document_id").notNull(), name: varchar("name", { length: 255 }).notNull(), - method: text("method").notNull(), - prepareStartTerm: datetime("prepare_start_term").notNull(), - prepareEndTerm: datetime("prepare_end_term").notNull(), - startTerm: datetime("start_term").notNull(), - endTerm: datetime("end_term").notNull(), - teamId: int("team_id").notNull(), - managerId: int("manager_id").notNull(), - purpose: text("purpose").notNull(), - target: text("target").notNull(), - detail: text("detail").notNull(), + method: text("method"), + prepareStartTerm: datetime("prepare_start_term"), + prepareEndTerm: datetime("prepare_end_term"), + startTerm: datetime("start_term"), + endTerm: datetime("end_term"), + teamId: int("team_id"), + managerId: int("manager_id"), + purpose: text("purpose"), + target: text("target"), + detail: text("detail"), note: text("note"), submittedAt: timestamp("submitted_at"), agendaId: int("agenda_id"), @@ -200,7 +200,7 @@ export const BudgetProposalIncomeRevision = mysqlTable( documentId: int("document_id").notNull(), budgetDomainEnumId: int("budget_domain_enum_id"), budgetDivisionIncomeEnumId: int("budget_division_income_enum_id"), - name: varchar("name", { length: 30 }), + name: varchar("name", { length: 30 }).notNull(), amount: int("amount"), detail: text("detail"), createdAt: timestamp("created_at").defaultNow().notNull(), diff --git a/packages/interface/src/api/proposal/endpoint/apiPrp004.ts b/packages/interface/src/api/proposal/endpoint/apiPrp004.ts new file mode 100644 index 0000000..059ddd9 --- /dev/null +++ b/packages/interface/src/api/proposal/endpoint/apiPrp004.ts @@ -0,0 +1,59 @@ +import { HttpStatusCode } from "axios"; +import { z } from "zod"; + +import { zOrgName } from "@sparcs-students/interface/common/stringLength"; +import { zId } from "@sparcs-students/interface/common/type/ids"; + +/** + * @version v0.1 + * @description 기구장단 권한으로 새로운 팀을 생성합니다. + */ + +const url = () => `/president/organizations/teams/team`; +const method = "POST"; +export const ApiOrg007RequestUrl = "/president/organizations/teams/team"; + +const requestParam = z.object({}); + +const requestQuery = z.object({}); + +const requestBody = z.object({ + organizationId: zId, + semesterId: zId, + name: zOrgName, + detail: z.coerce.string(), +}); + +const responseBodyMap = { + [HttpStatusCode.Created]: z.object({ + teamId: zId, + }), +}; + +const responseErrorMap = {}; + +const apiOrg007 = { + url, + method, + requestParam, + requestQuery, + requestBody, + responseBodyMap, + responseErrorMap, +}; + +type ApiOrg007RequestParam = z.infer; +type ApiOrg007RequestQuery = z.infer; +type ApiOrg007RequestBody = z.infer; +type ApiOrg007ResponseCreated = z.infer< + (typeof apiOrg007.responseBodyMap)[201] +>; + +export default apiOrg007; + +export type { + ApiOrg007RequestParam, + ApiOrg007RequestQuery, + ApiOrg007RequestBody, + ApiOrg007ResponseCreated, +}; From 428ee1af581343dd1a9890b540520cbf1ccfab8e Mon Sep 17 00:00:00 2001 From: Gerbera3090 Date: Sat, 30 Nov 2024 04:50:49 +0900 Subject: [PATCH 04/10] feat: api interface for apiprp004 --- .../src/api/proposal/endpoint/apiPrp004.ts | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/interface/src/api/proposal/endpoint/apiPrp004.ts b/packages/interface/src/api/proposal/endpoint/apiPrp004.ts index 059ddd9..e58540f 100644 --- a/packages/interface/src/api/proposal/endpoint/apiPrp004.ts +++ b/packages/interface/src/api/proposal/endpoint/apiPrp004.ts @@ -1,17 +1,18 @@ import { HttpStatusCode } from "axios"; import { z } from "zod"; -import { zOrgName } from "@sparcs-students/interface/common/stringLength"; import { zId } from "@sparcs-students/interface/common/type/ids"; /** * @version v0.1 - * @description 기구장단 권한으로 새로운 팀을 생성합니다. + * @description 기구장단 권한으로 새로운 project proposal 을 만들어 냅니다. + * 첫번째 revision도 같이 생성합니다. + * */ const url = () => `/president/organizations/teams/team`; const method = "POST"; -export const ApiOrg007RequestUrl = "/president/organizations/teams/team"; +export const ApiPrp004RequestUrl = "/president/organizations/teams/team"; const requestParam = z.object({}); @@ -20,8 +21,7 @@ const requestQuery = z.object({}); const requestBody = z.object({ organizationId: zId, semesterId: zId, - name: zOrgName, - detail: z.coerce.string(), + name: z.coerce.string().max(30), }); const responseBodyMap = { @@ -32,7 +32,7 @@ const responseBodyMap = { const responseErrorMap = {}; -const apiOrg007 = { +const apiPrp004 = { url, method, requestParam, @@ -42,18 +42,18 @@ const apiOrg007 = { responseErrorMap, }; -type ApiOrg007RequestParam = z.infer; -type ApiOrg007RequestQuery = z.infer; -type ApiOrg007RequestBody = z.infer; -type ApiOrg007ResponseCreated = z.infer< - (typeof apiOrg007.responseBodyMap)[201] +type ApiPrp004RequestParam = z.infer; +type ApiPrp004RequestQuery = z.infer; +type ApiPrp004RequestBody = z.infer; +type ApiPrp004ResponseCreated = z.infer< + (typeof apiPrp004.responseBodyMap)[201] >; -export default apiOrg007; +export default apiPrp004; export type { - ApiOrg007RequestParam, - ApiOrg007RequestQuery, - ApiOrg007RequestBody, - ApiOrg007ResponseCreated, + ApiPrp004RequestParam, + ApiPrp004RequestQuery, + ApiPrp004RequestBody, + ApiPrp004ResponseCreated, }; From 0de9ddb904eb66c34a06e83ad4e9a8a2cab62e02 Mon Sep 17 00:00:00 2001 From: Gerbera3090 Date: Sat, 30 Nov 2024 06:37:11 +0900 Subject: [PATCH 05/10] feat: api prp 004 for post new project proposal --- .../repository/organization.repository.ts | 62 +++++ .../service/organization.public.service.ts | 30 ++- .../controller/project-proposal.controller.ts | 14 +- .../project-proposal.module.ts | 9 +- .../repository/project-proposal.repository.ts | 222 +++++++++++++++++- .../service/project-proposal.service.ts | 95 ++++++++ .../src/api/proposal/endpoint/apiPrp004.ts | 7 +- packages/interface/src/api/proposal/index.ts | 3 + 8 files changed, 432 insertions(+), 10 deletions(-) diff --git a/packages/api/src/feature/organization/repository/organization.repository.ts b/packages/api/src/feature/organization/repository/organization.repository.ts index 2e8beff..04c4b0e 100644 --- a/packages/api/src/feature/organization/repository/organization.repository.ts +++ b/packages/api/src/feature/organization/repository/organization.repository.ts @@ -45,6 +45,68 @@ export class OrganizationRepository { return this.db.select().from(Organization).where(eq(Organization.id, id)); } + async selectOrganization( + target: Partial, + ): Promise { + const { + id, + name, + nameEng, + organizationTypeEnumId, + foundingYear, + startTerm, + endTerm, + } = target; + let query = this.db.select().from(Organization).$dynamic(); + + const whereConditions = []; + + if (id) { + whereConditions.push(eq(Organization.id, id)); + } + + if (name) { + whereConditions.push(eq(Organization.name, name)); + } + + if (nameEng) { + whereConditions.push(eq(Organization.nameEng, nameEng)); + } + + if (organizationTypeEnumId) { + whereConditions.push( + eq(Organization.organizationTypeEnumId, organizationTypeEnumId), + ); + } + + if (foundingYear) { + whereConditions.push(eq(Organization.foundingYear, foundingYear)); + } + + if (startTerm) { + whereConditions.push( + or(gte(Organization.endTerm, startTerm), isNull(Organization.endTerm)), + ); + } + + if (endTerm) { + whereConditions.push(lte(Organization.startTerm, endTerm)); + } + + // 삭제된 항목 제외 + whereConditions.push(isNull(Organization.deletedAt)); + + // 조건이 하나라도 있으면 AND로 묶어서 처리 + if (whereConditions.length > 0) { + query = query.where(and(...whereConditions)); + } + + // 쿼리 실행 + const res = await query.execute(); + + return res; + } + async getOrganizationWithPresidentById( organizationId: number, date: Date, diff --git a/packages/api/src/feature/organization/service/organization.public.service.ts b/packages/api/src/feature/organization/service/organization.public.service.ts index eb4a640..ce05085 100644 --- a/packages/api/src/feature/organization/service/organization.public.service.ts +++ b/packages/api/src/feature/organization/service/organization.public.service.ts @@ -80,7 +80,7 @@ export class OrganizationPublicService { /** * @param userId, organizationId, startTerm, endTerm - * @returns TeamMemeberT 해당 학기 해당 단체에 해당하는 TeamMemberT 객체를 리턴합니다. + * @returns OrganizationMemeberT 해당 학기 해당 단체에 해당하는 OrganizationMemberT 객체를 리턴합니다. * @description 해당 시기에 해당하는 OrganizationMember가 없으면 404 exception을 throw 합니다. */ async getOrganizationMemberByUserAndOrgAndDate( @@ -110,7 +110,7 @@ export class OrganizationPublicService { /** * @param userId, organizationId, semesterId - * @returns TeamMemeberT 해당 학기 해당 단체에 해당하는 TeamMemberT 객체를 리턴합니다. + * @returns OrganizationMemeberT 해당 학기 해당 단체에 해당하는 OrganizationMemberT 객체를 리턴합니다. * @description 해당 시기에 해당하는 OrganizationMember가 없으면 404 exception을 throw 합니다. */ async getOrganizationMemberByUserAndOrgAndSemester( @@ -127,4 +127,30 @@ export class OrganizationPublicService { endTerm, ); } + + /** + * @param organizationId, semesterId + * @returns boolean 단체가 해당 학기에 존재했으면 true, 아니면 false를 리턴합니다. + * @description organizationId에 해당하는 단체가 semesterId에 해당하는 학기에 존재하는지 확인합니다. + * 해당 시기에 해당하는 Organization이 없었더라도 404Error를 발생시키지 않습니다. + */ + async checkOrganizationInSemester( + organizationId: number, + semesterId: number, + ): Promise { + const semester = + await this.semesterPublicService.getSemesterById(semesterId); + const organizations = await this.organizationRepository.selectOrganization({ + id: organizationId, + startTerm: semester.startTerm, + endTerm: semester.endTerm, + }); + if (organizations.length > 1) { + throw new HttpException( + `Unreachable: Organization with ID ${organizationId} has multiple records.`, + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + return organizations.length !== 0; + } } diff --git a/packages/api/src/feature/proposal/project-proposal/controller/project-proposal.controller.ts b/packages/api/src/feature/proposal/project-proposal/controller/project-proposal.controller.ts index e52cf64..49c0eb9 100644 --- a/packages/api/src/feature/proposal/project-proposal/controller/project-proposal.controller.ts +++ b/packages/api/src/feature/proposal/project-proposal/controller/project-proposal.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Query, UsePipes } from "@nestjs/common"; +import { Body, Controller, Get, Post, Query, UsePipes } from "@nestjs/common"; import { ZodPipe } from "@sparcs-students/api/common/pipes/zod-pipe"; import { @@ -6,6 +6,10 @@ import { ApiPrp001RequestUrl, apiPrp001, ApiPrp001RequestQuery, + ApiPrp004RequestUrl, + apiPrp004, + ApiPrp004RequestBody, + ApiPrp004ResponseCreated, } from "@sparcs-students/interface/api/proposal/index"; import { ProjectProposalService } from "../service/project-proposal.service"; @@ -25,4 +29,12 @@ export class ProjectProposalController { query, ); } + + @Post(ApiPrp004RequestUrl) + @UsePipes(new ZodPipe(apiPrp004)) + async PostProjectProposal( + @Body() body: ApiPrp004RequestBody, + ): Promise { + return this.projectProposalService.postProjectProposal(body); + } } diff --git a/packages/api/src/feature/proposal/project-proposal/project-proposal.module.ts b/packages/api/src/feature/proposal/project-proposal/project-proposal.module.ts index c5b9529..84487b5 100644 --- a/packages/api/src/feature/proposal/project-proposal/project-proposal.module.ts +++ b/packages/api/src/feature/proposal/project-proposal/project-proposal.module.ts @@ -4,12 +4,19 @@ import { DrizzleModule } from "src/drizzle/drizzle.module"; import { OrganizationModule } from "src/feature/organization/organization.module"; import { UserModule } from "src/feature/user/user.module"; import { TeamModule } from "src/feature/organization/team/team.module"; +import { SemesterModule } from "src/feature/semester/semester.module"; import { ProjectProposalRepository } from "./repository/project-proposal.repository"; import { ProjectProposalService } from "./service/project-proposal.service"; import { ProjectProposalController } from "./controller/project-proposal.controller"; @Module({ - imports: [TeamModule, DrizzleModule, UserModule, OrganizationModule], + imports: [ + TeamModule, + DrizzleModule, + UserModule, + OrganizationModule, + SemesterModule, + ], providers: [ProjectProposalRepository, ProjectProposalService], controllers: [ProjectProposalController], }) diff --git a/packages/api/src/feature/proposal/project-proposal/repository/project-proposal.repository.ts b/packages/api/src/feature/proposal/project-proposal/repository/project-proposal.repository.ts index bc27a2b..c8332f2 100644 --- a/packages/api/src/feature/proposal/project-proposal/repository/project-proposal.repository.ts +++ b/packages/api/src/feature/proposal/project-proposal/repository/project-proposal.repository.ts @@ -11,8 +11,7 @@ import { } from "@sparcs-students/api/drizzle/schema"; import { ApiPrp001ResponseOK } from "@sparcs-students/interface/api/proposal/index"; import { AgendaAcceptedStatusE } from "@sparcs-students/interface/common/enum/meeting.enum"; - -import { and, eq, desc } from "drizzle-orm"; +import { asc, isNull, and, eq, desc } from "drizzle-orm"; import { MySql2Database } from "drizzle-orm/mysql2"; import { DrizzleAsyncProvider } from "src/drizzle/drizzle.provider"; @@ -67,7 +66,7 @@ export class ProjectProposalRepository { acceptedStatus: row.agendaExists && row.agendaAccepted ? AgendaAcceptedStatusE.Accepted - : AgendaAcceptedStatusE.Reject, + : AgendaAcceptedStatusE.Rejected, })); } @@ -132,4 +131,221 @@ export class ProjectProposalRepository { return res; } + + async selectProjectProposalWithRevision() // target: { + // projectProposal: Partial; + // projectProposalRevision: Partial; + // } + : Promise< + { + projectProposal: ProjectProposalT; + projectProposalRevision: ProjectProposalRevisionT; + }[] + > { + // TODO: 둘 다 join하는 것 나중에 만들 것임 + return []; + } + + async selectProjectProposal( + target: Partial & { orderByIdAsc?: boolean }, + ): Promise { + const { id, organizationId, semesterId, revisionId, orderByIdAsc } = target; + let query = this.db.select().from(ProjectProposal).$dynamic(); + + const whereConditions = []; + + if (id) { + whereConditions.push(eq(ProjectProposal.id, id)); + } + + if (organizationId) { + whereConditions.push(eq(ProjectProposal.organizationId, organizationId)); + } + + if (semesterId) { + whereConditions.push(eq(ProjectProposal.semesterId, semesterId)); + } + + if (revisionId) { + whereConditions.push(eq(ProjectProposal.revisionId, revisionId)); + } + + // 삭제된 항목 제외 + whereConditions.push(isNull(ProjectProposal.deletedAt)); + + // 조건이 하나라도 있으면 AND로 묶어서 처리 + if (whereConditions.length > 0) { + query = query.where(and(...whereConditions)); + } + + if (orderByIdAsc) { + query = query.orderBy(asc(ProjectProposal.id)); + } + + // 쿼리 실행 + const res = await query.execute(); + + return res; + } + + async insertProjectProposal( + organizationId: number, + semesterId: number, + ): Promise { + await this.db + .insert(ProjectProposal) + .values({ organizationId, semesterId }) + .execute(); + const res = await this.selectProjectProposal({ + organizationId, + semesterId, + orderByIdAsc: true, + }); + if (res.length === 0) { + return 0; + } + + return res[res.length - 1].id; + } + + async updateProjectProposal( + target: Partial, // 수정할 필드를 담은 객체 + condition: Partial, // 조건 + ): Promise { + const { id, organizationId, semesterId, revisionId } = condition; + + let query = this.db.update(ProjectProposal).set(target).$dynamic(); + + // 조건 설정 + const whereConditions = []; + + if (id) { + whereConditions.push(eq(ProjectProposal.id, id)); + } + + if (organizationId) { + whereConditions.push(eq(ProjectProposal.organizationId, organizationId)); + } + + if (semesterId) { + whereConditions.push(eq(ProjectProposal.semesterId, semesterId)); + } + + if (revisionId) { + whereConditions.push(eq(ProjectProposal.revisionId, revisionId)); + } + + // 삭제된 항목 제외 (deletedAt이 null이어야만 업데이트) + whereConditions.push(isNull(ProjectProposal.deletedAt)); + + // 조건이 하나라도 있으면 AND로 묶어서 처리 + if (whereConditions.length > 0) { + query = query.where(and(...whereConditions)); + } + + // 쿼리 실행 + await query.execute(); + + // 업데이트가 완료되었으므로 true 반환 + return true; + } + + async selectProjectProposalRevision( + target: Partial & { orderByIdAsc?: boolean }, + ): Promise { + const { id, documentId, name, orderByIdAsc } = target; + let query = this.db.select().from(ProjectProposalRevision).$dynamic(); + + const whereConditions = []; + + if (id) { + whereConditions.push(eq(ProjectProposalRevision.id, id)); + } + + if (documentId) { + whereConditions.push(eq(ProjectProposalRevision.documentId, documentId)); + } + + if (name) { + whereConditions.push(eq(ProjectProposalRevision.name, name)); + } + + // TODO: 나중에 다른 항목으로 조회가 필요할 경우 추가 + + // 삭제된 항목 제외 + whereConditions.push(isNull(ProjectProposalRevision.deletedAt)); + + // 조건이 하나라도 있으면 AND로 묶어서 처리 + if (whereConditions.length > 0) { + query = query.where(and(...whereConditions)); + } + + if (orderByIdAsc) { + query = query.orderBy(asc(ProjectProposalRevision.id)); + } + + // 쿼리 실행 + const res = await query.execute(); + + return res; + } + + async insertProjectProposalRevision( + documentId: number, + name: string, + ): Promise { + await this.db + .insert(ProjectProposalRevision) + .values({ documentId, name }) + .execute(); + const res = await this.selectProjectProposalRevision({ + documentId, + name, + orderByIdAsc: true, + }); + if (res.length === 0) { + return 0; + } + + return res[res.length - 1].id; + } + + async updateProjectProposalRevision( + target: Partial, // 수정할 필드를 담은 객체 + condition: Partial, // 조건 + ): Promise { + // 업데이트된 행 수를 반환 + const { id, documentId, name } = condition; + + let query = this.db.update(ProjectProposalRevision).set(target).$dynamic(); + + // 조건 설정 + const whereConditions = []; + + if (id) { + whereConditions.push(eq(ProjectProposalRevision.id, id)); + } + + if (documentId) { + whereConditions.push(eq(ProjectProposalRevision.documentId, documentId)); + } + + if (name) { + whereConditions.push(eq(ProjectProposalRevision.name, name)); + } + + // 삭제된 항목 제외 (deletedAt이 null이어야만 업데이트) + whereConditions.push(isNull(ProjectProposalRevision.deletedAt)); + + // 조건이 하나라도 있으면 AND로 묶어서 처리 + if (whereConditions.length > 0) { + query = query.where(and(...whereConditions)); + } + + // 쿼리 실행 + await query.execute(); + + // 업데이트된 행 수를 반환 + return true; + } } diff --git a/packages/api/src/feature/proposal/project-proposal/service/project-proposal.service.ts b/packages/api/src/feature/proposal/project-proposal/service/project-proposal.service.ts index bbb0dcf..13ef668 100644 --- a/packages/api/src/feature/proposal/project-proposal/service/project-proposal.service.ts +++ b/packages/api/src/feature/proposal/project-proposal/service/project-proposal.service.ts @@ -9,8 +9,11 @@ import { ApiPrp001RequestQuery, ApiPrp001ResponseOK, ApiPrp002ResponseOK, + ApiPrp004RequestBody, + ApiPrp004ResponseCreated, } from "@sparcs-students/interface/api/proposal/index"; import { UserPublicService } from "@sparcs-students/api/feature/user/service/user.public.service"; +import { SemesterPublicService } from "@sparcs-students/api/feature/semester/semester.public.service"; import { OrganizationPublicService } from "src/feature/organization/service/organization.public.service"; import { TeamPublicService } from "src/feature/organization/team/service/team.public.service"; import { ProjectProposalRepository } from "../repository/project-proposal.repository"; @@ -22,6 +25,7 @@ export class ProjectProposalService { private readonly organizationPublicService: OrganizationPublicService, private readonly userPublicService: UserPublicService, private readonly teamPublicService: TeamPublicService, + private readonly semesterPublicService: SemesterPublicService, ) {} async getProjectProposalsForStudentsBySemesterId( @@ -109,4 +113,95 @@ export class ProjectProposalService { budgetProposals: undefined, }; } + + async postProjectProposal( + body: ApiPrp004RequestBody, + ): Promise { + // semetster 존재하는 지 확인 + await this.semesterPublicService.getSemesterById(body.semesterId); + // organization 존재하는 지 확인 + await this.organizationPublicService.getOrganizationById( + body.organizationId, + ); + // 해당 semester에 organization이 존재하는 지 확인 + const checkOrganizationInSemester = + await this.organizationPublicService.checkOrganizationInSemester( + body.organizationId, + body.semesterId, + ); + if (!checkOrganizationInSemester) { + throw new NotFoundException( + `Organization with ID ${body.organizationId} not found in semester with ID ${body.semesterId}`, + ); + } + + // 제대로 들어갔는 지 count 하기 위한 용도 + const count1ProjectProposal = + await this.projectProposalRepository.selectProjectProposal({ + organizationId: body.organizationId, + semesterId: body.semesterId, + }); + + // ProjectProposal 생성 + const resPrpId = await this.projectProposalRepository.insertProjectProposal( + body.organizationId, + body.semesterId, + ); + + const count2ProjectProposal = + await this.projectProposalRepository.selectProjectProposal({ + organizationId: body.organizationId, + semesterId: body.semesterId, + }); + // ProjectProposal 생성이 제대로 되었는 지 확인 + if (count1ProjectProposal.length + 1 !== count2ProjectProposal.length) { + throw new HttpException( + "ProjectProposal creation failed", + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + const count1ProjectProposalRevision = + await this.projectProposalRepository.selectProjectProposalRevision({ + documentId: resPrpId, + name: body.name, + }); + + // ProjectProposalRevision 생성 + const resPrpRevId = + await this.projectProposalRepository.insertProjectProposalRevision( + resPrpId, + body.name, + ); + + const count2ProjectProposalRevision = + await this.projectProposalRepository.selectProjectProposalRevision({ + documentId: resPrpId, + name: body.name, + }); + + if ( + count1ProjectProposalRevision.length + 1 !== + count2ProjectProposalRevision.length + ) { + throw new HttpException( + "ProjectProposalRevision creation failed", + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + // ProjectProposal의 revisionId를 업데이트 + await this.projectProposalRepository.updateProjectProposal( + { + revisionId: resPrpId, + }, + { + revisionId: resPrpRevId, + }, + ); + + return { + projectProposalId: resPrpId, + }; + } } diff --git a/packages/interface/src/api/proposal/endpoint/apiPrp004.ts b/packages/interface/src/api/proposal/endpoint/apiPrp004.ts index e58540f..5d3d4c2 100644 --- a/packages/interface/src/api/proposal/endpoint/apiPrp004.ts +++ b/packages/interface/src/api/proposal/endpoint/apiPrp004.ts @@ -10,9 +10,10 @@ import { zId } from "@sparcs-students/interface/common/type/ids"; * */ -const url = () => `/president/organizations/teams/team`; +const url = () => `/president/proposals/project-proposals/project-proposal`; const method = "POST"; -export const ApiPrp004RequestUrl = "/president/organizations/teams/team"; +export const ApiPrp004RequestUrl = + "/president/proposals/project-proposals/project-proposal"; const requestParam = z.object({}); @@ -26,7 +27,7 @@ const requestBody = z.object({ const responseBodyMap = { [HttpStatusCode.Created]: z.object({ - teamId: zId, + projectProposalId: zId, }), }; diff --git a/packages/interface/src/api/proposal/index.ts b/packages/interface/src/api/proposal/index.ts index 41fd16a..4c7b848 100644 --- a/packages/interface/src/api/proposal/index.ts +++ b/packages/interface/src/api/proposal/index.ts @@ -3,3 +3,6 @@ export { default as apiPrp001 } from "./endpoint/apiPrp001"; export * from "./endpoint/apiPrp002"; export { default as apiPrp002 } from "./endpoint/apiPrp002"; + +export * from "./endpoint/apiPrp004"; +export { default as apiPrp004 } from "./endpoint/apiPrp004"; From 1bc3d2837682841c6db017ca08052a583212d072 Mon Sep 17 00:00:00 2001 From: Gerbera3090 Date: Mon, 2 Dec 2024 02:04:12 +0900 Subject: [PATCH 06/10] fix: api prp 004 request body fix --- packages/interface/src/api/proposal/endpoint/apiPrp004.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/interface/src/api/proposal/endpoint/apiPrp004.ts b/packages/interface/src/api/proposal/endpoint/apiPrp004.ts index 5d3d4c2..de2e50f 100644 --- a/packages/interface/src/api/proposal/endpoint/apiPrp004.ts +++ b/packages/interface/src/api/proposal/endpoint/apiPrp004.ts @@ -22,7 +22,7 @@ const requestQuery = z.object({}); const requestBody = z.object({ organizationId: zId, semesterId: zId, - name: z.coerce.string().max(30), + name: z.coerce.string().max(255), }); const responseBodyMap = { From 3a2162603a0bd258c65104a59fbc42add1bdfe83 Mon Sep 17 00:00:00 2001 From: Gerbera3090 Date: Mon, 2 Dec 2024 02:25:49 +0900 Subject: [PATCH 07/10] feat: api prp 005 interface file --- .../controller/project-proposal.controller.ts | 23 ++++++- .../src/api/proposal/endpoint/apiPrp005.ts | 69 +++++++++++++++++++ packages/interface/src/api/proposal/index.ts | 3 + 3 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 packages/interface/src/api/proposal/endpoint/apiPrp005.ts diff --git a/packages/api/src/feature/proposal/project-proposal/controller/project-proposal.controller.ts b/packages/api/src/feature/proposal/project-proposal/controller/project-proposal.controller.ts index 49c0eb9..35f44dd 100644 --- a/packages/api/src/feature/proposal/project-proposal/controller/project-proposal.controller.ts +++ b/packages/api/src/feature/proposal/project-proposal/controller/project-proposal.controller.ts @@ -1,4 +1,12 @@ -import { Body, Controller, Get, Post, Query, UsePipes } from "@nestjs/common"; +import { + Body, + Controller, + Get, + Post, + Put, + Query, + UsePipes, +} from "@nestjs/common"; import { ZodPipe } from "@sparcs-students/api/common/pipes/zod-pipe"; import { @@ -10,6 +18,9 @@ import { apiPrp004, ApiPrp004RequestBody, ApiPrp004ResponseCreated, + ApiPrp005RequestUrl, + apiPrp005, + ApiPrp005RequestBody, } from "@sparcs-students/interface/api/proposal/index"; import { ProjectProposalService } from "../service/project-proposal.service"; @@ -32,9 +43,17 @@ export class ProjectProposalController { @Post(ApiPrp004RequestUrl) @UsePipes(new ZodPipe(apiPrp004)) - async PostProjectProposal( + async postProjectProposal( @Body() body: ApiPrp004RequestBody, ): Promise { return this.projectProposalService.postProjectProposal(body); } + + @Put(ApiPrp005RequestUrl) + @UsePipes(new ZodPipe(apiPrp005)) + async putProjectProposal( + @Body() body: ApiPrp005RequestBody, + ): Promise { + return `this.projectProposalService.putProjectProposal(${JSON.stringify(body)})`; + } } diff --git a/packages/interface/src/api/proposal/endpoint/apiPrp005.ts b/packages/interface/src/api/proposal/endpoint/apiPrp005.ts new file mode 100644 index 0000000..b129c25 --- /dev/null +++ b/packages/interface/src/api/proposal/endpoint/apiPrp005.ts @@ -0,0 +1,69 @@ +import { HttpStatusCode } from "axios"; +import { z } from "zod"; + +import { zId } from "@sparcs-students/interface/common/type/ids"; + +/** + * @version v0.1 + * @description 각 매니저들의 권한으로, project-proposal의 내용을 수정합니다. + * 만약 해당 project-proposal-revision이 submit 되어 있는 상태라면, 새로운 proposal-revision을 만들어서 임시저장 해둡니다 + */ + +const url = (projectProposalId: number) => + `/manager/proposals/project-proposals/project-proposal/${projectProposalId}`; +const method = "PUT"; +export const ApiPrp005RequestUrl = + "/manager/proposals/project-proposals/project-proposal/:projectProposalId"; + +const requestParam = z.object({ + projectProposalId: zId, +}); + +const requestQuery = z.object({}); + +const requestBody = z.object({ + name: z.coerce.string().max(255), + method: z.coerce.string().optional(), + prepareStartTerm: z.date().optional(), + prepareEndTerm: z.date().optional(), + startTerm: z.date().optional(), + endTerm: z.date().optional(), + teamId: zId.optional(), // Id(Team) + managerId: zId.optional(), // Id(User) + purpose: z.coerce.string().optional(), + target: z.coerce.string().optional(), + detail: z.coerce.string().optional(), + note: z.coerce.string().optional(), +}); + +const responseBodyMap = { + [HttpStatusCode.Ok]: z.object({ + projectProposalId: zId, + }), +}; + +const responseErrorMap = {}; + +const apiPrp005 = { + url, + method, + requestParam, + requestQuery, + requestBody, + responseBodyMap, + responseErrorMap, +}; + +type ApiPrp005RequestParam = z.infer; +type ApiPrp005RequestQuery = z.infer; +type ApiPrp005RequestBody = z.infer; +type ApiPrp005ResponseOK = z.infer<(typeof apiPrp005.responseBodyMap)[200]>; + +export default apiPrp005; + +export type { + ApiPrp005RequestParam, + ApiPrp005RequestQuery, + ApiPrp005RequestBody, + ApiPrp005ResponseOK, +}; diff --git a/packages/interface/src/api/proposal/index.ts b/packages/interface/src/api/proposal/index.ts index 4c7b848..89401ec 100644 --- a/packages/interface/src/api/proposal/index.ts +++ b/packages/interface/src/api/proposal/index.ts @@ -6,3 +6,6 @@ export { default as apiPrp002 } from "./endpoint/apiPrp002"; export * from "./endpoint/apiPrp004"; export { default as apiPrp004 } from "./endpoint/apiPrp004"; + +export * from "./endpoint/apiPrp005"; +export { default as apiPrp005 } from "./endpoint/apiPrp005"; From 121b077fb09b3579ab7c7df2bf0075b38b4a4d55 Mon Sep 17 00:00:00 2001 From: Gerbera3090 Date: Mon, 2 Dec 2024 03:27:41 +0900 Subject: [PATCH 08/10] feat: api prp 005 for updating project prp content --- .../service/organization.public.service.ts | 2 +- .../controller/project-proposal.controller.ts | 8 +- .../repository/project-proposal.repository.ts | 4 +- .../service/project-proposal.service.ts | 108 ++++++++++++++++++ .../src/api/proposal/endpoint/apiPrp005.ts | 8 +- 5 files changed, 121 insertions(+), 9 deletions(-) diff --git a/packages/api/src/feature/organization/service/organization.public.service.ts b/packages/api/src/feature/organization/service/organization.public.service.ts index ce05085..b41b48e 100644 --- a/packages/api/src/feature/organization/service/organization.public.service.ts +++ b/packages/api/src/feature/organization/service/organization.public.service.ts @@ -115,8 +115,8 @@ export class OrganizationPublicService { */ async getOrganizationMemberByUserAndOrgAndSemester( userId: number, - semesterId: number, organizationId: number, + semesterId: number, ): Promise { const { startTerm, endTerm } = await this.semesterPublicService.getSemesterById(semesterId); diff --git a/packages/api/src/feature/proposal/project-proposal/controller/project-proposal.controller.ts b/packages/api/src/feature/proposal/project-proposal/controller/project-proposal.controller.ts index 35f44dd..2c7d74d 100644 --- a/packages/api/src/feature/proposal/project-proposal/controller/project-proposal.controller.ts +++ b/packages/api/src/feature/proposal/project-proposal/controller/project-proposal.controller.ts @@ -2,6 +2,7 @@ import { Body, Controller, Get, + Param, Post, Put, Query, @@ -20,7 +21,9 @@ import { ApiPrp004ResponseCreated, ApiPrp005RequestUrl, apiPrp005, + ApiPrp005RequestParam, ApiPrp005RequestBody, + ApiPrp005ResponseOK, } from "@sparcs-students/interface/api/proposal/index"; import { ProjectProposalService } from "../service/project-proposal.service"; @@ -52,8 +55,9 @@ export class ProjectProposalController { @Put(ApiPrp005RequestUrl) @UsePipes(new ZodPipe(apiPrp005)) async putProjectProposal( + @Param() param: ApiPrp005RequestParam, @Body() body: ApiPrp005RequestBody, - ): Promise { - return `this.projectProposalService.putProjectProposal(${JSON.stringify(body)})`; + ): Promise { + return this.projectProposalService.putProjectProposalContent(param, body); } } diff --git a/packages/api/src/feature/proposal/project-proposal/repository/project-proposal.repository.ts b/packages/api/src/feature/proposal/project-proposal/repository/project-proposal.repository.ts index c8332f2..07a3c8e 100644 --- a/packages/api/src/feature/proposal/project-proposal/repository/project-proposal.repository.ts +++ b/packages/api/src/feature/proposal/project-proposal/repository/project-proposal.repository.ts @@ -311,13 +311,13 @@ export class ProjectProposalRepository { } async updateProjectProposalRevision( - target: Partial, // 수정할 필드를 담은 객체 + values: Partial, // 수정할 필드를 담은 객체 condition: Partial, // 조건 ): Promise { // 업데이트된 행 수를 반환 const { id, documentId, name } = condition; - let query = this.db.update(ProjectProposalRevision).set(target).$dynamic(); + let query = this.db.update(ProjectProposalRevision).set(values).$dynamic(); // 조건 설정 const whereConditions = []; diff --git a/packages/api/src/feature/proposal/project-proposal/service/project-proposal.service.ts b/packages/api/src/feature/proposal/project-proposal/service/project-proposal.service.ts index 13ef668..c94b207 100644 --- a/packages/api/src/feature/proposal/project-proposal/service/project-proposal.service.ts +++ b/packages/api/src/feature/proposal/project-proposal/service/project-proposal.service.ts @@ -11,6 +11,9 @@ import { ApiPrp002ResponseOK, ApiPrp004RequestBody, ApiPrp004ResponseCreated, + ApiPrp005RequestBody, + ApiPrp005RequestParam, + ApiPrp005ResponseOK, } from "@sparcs-students/interface/api/proposal/index"; import { UserPublicService } from "@sparcs-students/api/feature/user/service/user.public.service"; import { SemesterPublicService } from "@sparcs-students/api/feature/semester/semester.public.service"; @@ -204,4 +207,109 @@ export class ProjectProposalService { projectProposalId: resPrpId, }; } + + async putProjectProposalContent( + param: ApiPrp005RequestParam, + body: ApiPrp005RequestBody, + ): Promise { + // ProjectProposalRevision 존재하는 지 확인 + const prpRev = + await this.projectProposalRepository.selectProjectProposalRevision({ + documentId: param.projectProposalId, + orderByIdAsc: true, + }); + if (prpRev.length === 0) { + throw new NotFoundException( + `ProjectProposalRevision with ID ${param.projectProposalId} not found`, + ); + } + const projectProposalRevision = prpRev[prpRev.length - 1]; + + if (body.teamId || body.managerId) { + const prp = await this.projectProposalRepository.selectProjectProposal({ + id: projectProposalRevision.documentId, + }); + if (prp.length === 0) { + throw new NotFoundException( + `ProjectProposal with ID ${projectProposalRevision.documentId} not found`, + ); + } + const projectProposal = prp[0]; + const { organizationId } = projectProposal; + // team, manager의 유효성 검사 + if (body.teamId) { + const team = await this.teamPublicService.getTeamById(body.teamId); + if (team.organizationId !== organizationId) { + throw new HttpException( + `Team with ID ${body.teamId} is not in Organization with ID ${organizationId}`, + HttpStatus.BAD_REQUEST, + ); + } + } + if (body.managerId) { + await this.userPublicService.getUserById(body.managerId); + const checkManagerInOrganization = + await this.organizationPublicService.getOrganizationMemberByUserAndOrgAndSemester( + body.managerId, + organizationId, + projectProposal.semesterId, + ); + if (!checkManagerInOrganization) { + throw new HttpException( + `User with ID ${body.managerId} is not in Organization with ID ${organizationId} and Semester with ID ${projectProposal.semesterId}`, + HttpStatus.BAD_REQUEST, + ); + } + } + } + // TODO: prepareStartTerm, prepareEndTerm, startTerm, endTerm의 유효성 검사 (이건 총학 정책이 필요할 듯) + + // 검증용 파라미터들 + let projectProposalRevisionUpdated: boolean; + + if (projectProposalRevision.submittedAt !== null) { + // ProPrpRev가 제출된 상태 => 새로운 PrpRev를 생성해야 함 + const resPrpRevId = + await this.projectProposalRepository.insertProjectProposalRevision( + param.projectProposalId, + body.name, + ); + if (resPrpRevId === 0) { + throw new HttpException( + "ProjectProposalRevision creation failed", + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + // ProjectProposalRevision의 내용을 업데이트 + projectProposalRevisionUpdated = + await this.projectProposalRepository.updateProjectProposalRevision( + { + ...body, + }, + { + id: resPrpRevId, + }, + ); + } else { + // ProPrpRev가 제출되지 않은 상태 => 해당 PrpRev를 업데이트 + projectProposalRevisionUpdated = + await this.projectProposalRepository.updateProjectProposalRevision( + { + ...body, + }, + { + id: projectProposalRevision.id, + }, + ); + } + if (!projectProposalRevisionUpdated) { + throw new HttpException( + "ProjectProposalRevision update failed", + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + return { projectProposalId: param.projectProposalId }; + } } diff --git a/packages/interface/src/api/proposal/endpoint/apiPrp005.ts b/packages/interface/src/api/proposal/endpoint/apiPrp005.ts index b129c25..32d9bb8 100644 --- a/packages/interface/src/api/proposal/endpoint/apiPrp005.ts +++ b/packages/interface/src/api/proposal/endpoint/apiPrp005.ts @@ -24,10 +24,10 @@ const requestQuery = z.object({}); const requestBody = z.object({ name: z.coerce.string().max(255), method: z.coerce.string().optional(), - prepareStartTerm: z.date().optional(), - prepareEndTerm: z.date().optional(), - startTerm: z.date().optional(), - endTerm: z.date().optional(), + prepareStartTerm: z.coerce.date().optional(), + prepareEndTerm: z.coerce.date().optional(), + startTerm: z.coerce.date().optional(), + endTerm: z.coerce.date().optional(), teamId: zId.optional(), // Id(Team) managerId: zId.optional(), // Id(User) purpose: z.coerce.string().optional(), From 98e9fd25abd82d5b0b91166f3e440109a52d6f3d Mon Sep 17 00:00:00 2001 From: Gerbera3090 Date: Mon, 2 Dec 2024 06:47:24 +0900 Subject: [PATCH 09/10] feat: api prp 006 for put submits unsubmitted revisions --- .../controller/project-proposal.controller.ts | 12 +++ .../repository/project-proposal.repository.ts | 76 ++++++++++++++++-- .../service/project-proposal.service.ts | 80 ++++++++++++++++++- .../src/api/proposal/endpoint/apiPrp006.ts | 65 +++++++++++++++ packages/interface/src/api/proposal/index.ts | 3 + 5 files changed, 229 insertions(+), 7 deletions(-) create mode 100644 packages/interface/src/api/proposal/endpoint/apiPrp006.ts diff --git a/packages/api/src/feature/proposal/project-proposal/controller/project-proposal.controller.ts b/packages/api/src/feature/proposal/project-proposal/controller/project-proposal.controller.ts index 2c7d74d..d262f73 100644 --- a/packages/api/src/feature/proposal/project-proposal/controller/project-proposal.controller.ts +++ b/packages/api/src/feature/proposal/project-proposal/controller/project-proposal.controller.ts @@ -24,6 +24,10 @@ import { ApiPrp005RequestParam, ApiPrp005RequestBody, ApiPrp005ResponseOK, + ApiPrp006RequestUrl, + apiPrp006, + ApiPrp006RequestBody, + ApiPrp006ResponseOK, } from "@sparcs-students/interface/api/proposal/index"; import { ProjectProposalService } from "../service/project-proposal.service"; @@ -60,4 +64,12 @@ export class ProjectProposalController { ): Promise { return this.projectProposalService.putProjectProposalContent(param, body); } + + @Put(ApiPrp006RequestUrl) + @UsePipes(new ZodPipe(apiPrp006)) + async putProjectProposalSubmit( + @Body() body: ApiPrp006RequestBody, + ): Promise { + return this.projectProposalService.putProjectProposalSubmit(body); + } } diff --git a/packages/api/src/feature/proposal/project-proposal/repository/project-proposal.repository.ts b/packages/api/src/feature/proposal/project-proposal/repository/project-proposal.repository.ts index 07a3c8e..28654db 100644 --- a/packages/api/src/feature/proposal/project-proposal/repository/project-proposal.repository.ts +++ b/packages/api/src/feature/proposal/project-proposal/repository/project-proposal.repository.ts @@ -11,7 +11,8 @@ import { } from "@sparcs-students/api/drizzle/schema"; import { ApiPrp001ResponseOK } from "@sparcs-students/interface/api/proposal/index"; import { AgendaAcceptedStatusE } from "@sparcs-students/interface/common/enum/meeting.enum"; -import { asc, isNull, and, eq, desc } from "drizzle-orm"; + +import { asc, isNull, and, eq, desc, inArray } from "drizzle-orm"; import { MySql2Database } from "drizzle-orm/mysql2"; import { DrizzleAsyncProvider } from "src/drizzle/drizzle.provider"; @@ -318,22 +319,17 @@ export class ProjectProposalRepository { const { id, documentId, name } = condition; let query = this.db.update(ProjectProposalRevision).set(values).$dynamic(); - // 조건 설정 const whereConditions = []; - if (id) { whereConditions.push(eq(ProjectProposalRevision.id, id)); } - if (documentId) { whereConditions.push(eq(ProjectProposalRevision.documentId, documentId)); } - if (name) { whereConditions.push(eq(ProjectProposalRevision.name, name)); } - // 삭제된 항목 제외 (deletedAt이 null이어야만 업데이트) whereConditions.push(isNull(ProjectProposalRevision.deletedAt)); @@ -348,4 +344,72 @@ export class ProjectProposalRepository { // 업데이트된 행 수를 반환 return true; } + + async selectUnsubmittedProjectProposalRevisionWithProjectProposal( + organizationId: number, + semesterId: number, + ): Promise { + // 가장 최신의 updatedAt 값을 가져오는 서브쿼리 + const res = await this.db + .select() + .from(ProjectProposalRevision) + .innerJoin( + ProjectProposal, + eq(ProjectProposal.id, ProjectProposalRevision.documentId), + ) + .where( + and( + eq(ProjectProposal.organizationId, organizationId), + eq(ProjectProposal.semesterId, semesterId), + isNull(ProjectProposalRevision.submittedAt), + isNull(ProjectProposalRevision.deletedAt), + ), + ); + + return res.map(row => ({ + projectProposal: row.project_proposal, + projectProposalRevision: row.project_proposal_revision, + })); + } + + async updateProjectProposalRevisionSubmit( + targetIds: { + projectProposalId: number; + projectProposalRevisionId: number; + }[], + ): Promise { + const revisionIds = targetIds.map(ids => ids.projectProposalRevisionId); + + await this.db.transaction(async tx => { + // submittedAt을 현재 시간으로 업데이트 + tx.update(ProjectProposalRevision) + .set({ submittedAt: new Date() }) + .where(inArray(ProjectProposalRevision.id, revisionIds)) + .execute(); + + // ProjectProposal의 revisionId를 업데이트 + // 여러 개의 업데이트를 트랜잭션 안에서 한 번에 처리 + const updateQueries = targetIds.map( + ({ projectProposalId, projectProposalRevisionId }) => + tx + .update(ProjectProposal) + .set({ revisionId: projectProposalRevisionId }) + .where(eq(ProjectProposal.id, projectProposalId)) + .execute(), + ); + + // 모든 업데이트가 병렬로 실행되도록 Promise.all 사용 + await Promise.all(updateQueries); + + // 위의 코드는 아래와 같이 forEach로도 작성 가능 + // targetIds.forEach(ids => { + // tx.update(ProjectProposal) + // .set({ revisionId: ids.projectProposalRevisionId }) + // .where(eq(ProjectProposal.id, ids.projectProposalId)) + // .execute(); + // }); + }); + + return true; + } } diff --git a/packages/api/src/feature/proposal/project-proposal/service/project-proposal.service.ts b/packages/api/src/feature/proposal/project-proposal/service/project-proposal.service.ts index c94b207..de78133 100644 --- a/packages/api/src/feature/proposal/project-proposal/service/project-proposal.service.ts +++ b/packages/api/src/feature/proposal/project-proposal/service/project-proposal.service.ts @@ -14,6 +14,8 @@ import { ApiPrp005RequestBody, ApiPrp005RequestParam, ApiPrp005ResponseOK, + ApiPrp006RequestBody, + ApiPrp006ResponseOK, } from "@sparcs-students/interface/api/proposal/index"; import { UserPublicService } from "@sparcs-students/api/feature/user/service/user.public.service"; import { SemesterPublicService } from "@sparcs-students/api/feature/semester/semester.public.service"; @@ -220,7 +222,7 @@ export class ProjectProposalService { }); if (prpRev.length === 0) { throw new NotFoundException( - `ProjectProposalRevision with ID ${param.projectProposalId} not found`, + `ProjectProposalRevision with ProjectProposalID ${param.projectProposalId} not found`, ); } const projectProposalRevision = prpRev[prpRev.length - 1]; @@ -312,4 +314,80 @@ export class ProjectProposalService { return { projectProposalId: param.projectProposalId }; } + + async putProjectProposalSubmit( + body: ApiPrp006RequestBody, + ): Promise { + await this.organizationPublicService.checkOrganizationInSemester( + body.organizationId, + body.semesterId, + ); + + // ProjectProposalRevision 존재하는 지 확인 + const unsubmittedPrpRevs = + await this.projectProposalRepository.selectUnsubmittedProjectProposalRevisionWithProjectProposal( + body.organizationId, + body.semesterId, + ); + if (unsubmittedPrpRevs.length === 0) { + throw new NotFoundException( + `Unsubmitted ProjectProposalRevision with Organization ID ${body.organizationId} and Semester ID ${body.semesterId} not found`, + ); + } + // ProjectProposalRevision 제출이 가능한 지 확인 + await Promise.all( + unsubmittedPrpRevs.map(async row => { + const revision = row.projectProposalRevision; + + // deleted_at 제외하고, 다른 값들이 null인지 체크 + const hasNullFields = Object.keys(revision) + .filter( + key => + key !== "deletedAt" && + key !== "agendaId" && + key !== "submittedAt", + ) + .some(key => revision[key] === null); + + if (hasNullFields) { + const nullKeys = Object.keys(revision).filter( + key => revision[key] === null, + ); + throw new HttpException( + `ProjectProposalRevision with ID ${revision.id} has null fields, name: ${revision.name} || keys: ${JSON.stringify(nullKeys)}`, + HttpStatus.UNPROCESSABLE_ENTITY, + ); + } + }), + ); + // ProjectProposalRevision 제출 + const unsubmittedPrpRevIds = unsubmittedPrpRevs.map(prpRev => ({ + projectProposalId: prpRev.projectProposal.id, + projectProposalRevisionId: prpRev.projectProposalRevision.id, + })); + + await this.projectProposalRepository.updateProjectProposalRevisionSubmit( + unsubmittedPrpRevIds, + ); + + // ProjectProposalRevision 제출이 제대로 되었는 지 확인 + const unsubmittedPrpRevCheck = + await this.projectProposalRepository.selectUnsubmittedProjectProposalRevisionWithProjectProposal( + body.organizationId, + body.semesterId, + ); + + if (unsubmittedPrpRevCheck.length !== 0) { + throw new HttpException( + "ProjectProposalRevision submission failed", + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + + // ProjectProposal의 revisionId를 업데이트 + + return { + submittedIds: unsubmittedPrpRevIds, + }; + } } diff --git a/packages/interface/src/api/proposal/endpoint/apiPrp006.ts b/packages/interface/src/api/proposal/endpoint/apiPrp006.ts new file mode 100644 index 0000000..6d2f29c --- /dev/null +++ b/packages/interface/src/api/proposal/endpoint/apiPrp006.ts @@ -0,0 +1,65 @@ +import { HttpStatusCode } from "axios"; +import { z } from "zod"; + +import { zId } from "@sparcs-students/interface/common/type/ids"; + +/** + * @version v0.1 + * @description 각 기구장들의 권한으로, project-proposal의 내용을 submit하고 revision_id를 갱신합니다. + * 만약 기존에 제출한 내용과 다른 점이 없으면, 404에러를 던집니다. + * 만약 비어있는 내용이 존재할 경우, 422 에러를 던집니다. + * 기존의 제출되었던 내용들은 다시 제출하지 않습니다.. + */ + +const url = () => `/chief/proposals/project-proposals/project-proposal/submit`; +const method = "PUT"; +export const ApiPrp006RequestUrl = + "/chief/proposals/project-proposals/project-proposal/submit"; + +const requestParam = z.object({ + projectProposalId: zId, +}); + +const requestQuery = z.object({}); + +const requestBody = z.object({ + organizationId: zId, + semesterId: zId, +}); + +const responseBodyMap = { + [HttpStatusCode.Ok]: z.object({ + submittedIds: z + .object({ + projectProposalId: zId, + projectProposalRevisionId: zId, + }) + .array(), + }), +}; + +const responseErrorMap = {}; + +const apiPrp006 = { + url, + method, + requestParam, + requestQuery, + requestBody, + responseBodyMap, + responseErrorMap, +}; + +type ApiPrp006RequestParam = z.infer; +type ApiPrp006RequestQuery = z.infer; +type ApiPrp006RequestBody = z.infer; +type ApiPrp006ResponseOK = z.infer<(typeof apiPrp006.responseBodyMap)[200]>; + +export default apiPrp006; + +export type { + ApiPrp006RequestParam, + ApiPrp006RequestQuery, + ApiPrp006RequestBody, + ApiPrp006ResponseOK, +}; diff --git a/packages/interface/src/api/proposal/index.ts b/packages/interface/src/api/proposal/index.ts index 89401ec..12b6e7a 100644 --- a/packages/interface/src/api/proposal/index.ts +++ b/packages/interface/src/api/proposal/index.ts @@ -9,3 +9,6 @@ export { default as apiPrp004 } from "./endpoint/apiPrp004"; export * from "./endpoint/apiPrp005"; export { default as apiPrp005 } from "./endpoint/apiPrp005"; + +export * from "./endpoint/apiPrp006"; +export { default as apiPrp006 } from "./endpoint/apiPrp006"; From 598cf4ab17ee31c2cd8f5b0feae97cf873a4ad62 Mon Sep 17 00:00:00 2001 From: Gerbera3090 Date: Mon, 2 Dec 2024 08:32:46 +0900 Subject: [PATCH 10/10] feat: powerful select repository method --- .../repository/project-proposal.repository.ts | 50 +++++++++++++------ .../service/project-proposal.service.ts | 2 +- 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/packages/api/src/feature/proposal/project-proposal/repository/project-proposal.repository.ts b/packages/api/src/feature/proposal/project-proposal/repository/project-proposal.repository.ts index 28654db..55635ce 100644 --- a/packages/api/src/feature/proposal/project-proposal/repository/project-proposal.repository.ts +++ b/packages/api/src/feature/proposal/project-proposal/repository/project-proposal.repository.ts @@ -12,7 +12,7 @@ import { import { ApiPrp001ResponseOK } from "@sparcs-students/interface/api/proposal/index"; import { AgendaAcceptedStatusE } from "@sparcs-students/interface/common/enum/meeting.enum"; -import { asc, isNull, and, eq, desc, inArray } from "drizzle-orm"; +import { asc, isNull, and, eq, desc, inArray, isNotNull } from "drizzle-orm"; import { MySql2Database } from "drizzle-orm/mysql2"; import { DrizzleAsyncProvider } from "src/drizzle/drizzle.provider"; @@ -148,9 +148,13 @@ export class ProjectProposalRepository { } async selectProjectProposal( - target: Partial & { orderByIdAsc?: boolean }, + target: Partial, + isNullCondition?: Partial>, + orderByCondition?: Partial< + Record + >[], ): Promise { - const { id, organizationId, semesterId, revisionId, orderByIdAsc } = target; + const { id, organizationId, semesterId, revisionId } = target; let query = this.db.select().from(ProjectProposal).$dynamic(); const whereConditions = []; @@ -174,18 +178,31 @@ export class ProjectProposalRepository { // 삭제된 항목 제외 whereConditions.push(isNull(ProjectProposal.deletedAt)); + if (isNullCondition) { + Object.entries(isNullCondition).forEach(([key, value]) => { + whereConditions.push( + value + ? isNull(ProjectProposal[key as keyof ProjectProposalT]) + : isNotNull(ProjectProposal[key as keyof ProjectProposalT]), + ); + }); + } + // 조건이 하나라도 있으면 AND로 묶어서 처리 if (whereConditions.length > 0) { query = query.where(and(...whereConditions)); } - - if (orderByIdAsc) { - query = query.orderBy(asc(ProjectProposal.id)); + const orderByConditions = orderByCondition.map(order => { + const [key, value] = Object.entries(order)[0]; // 각 항목을 키와 값으로 분리 + return value === "ASC" + ? asc(ProjectProposal[key as keyof ProjectProposalT]) + : desc(ProjectProposal[key as keyof ProjectProposalT]); + }); + if (orderByConditions.length > 0) { + query = query.orderBy(...orderByConditions); } - // 쿼리 실행 const res = await query.execute(); - return res; } @@ -197,11 +214,14 @@ export class ProjectProposalRepository { .insert(ProjectProposal) .values({ organizationId, semesterId }) .execute(); - const res = await this.selectProjectProposal({ - organizationId, - semesterId, - orderByIdAsc: true, - }); + const res = await this.selectProjectProposal( + { + organizationId, + semesterId, + }, + {}, + [{ id: "ASC" }], + ); if (res.length === 0) { return 0; } @@ -252,7 +272,9 @@ export class ProjectProposalRepository { } async selectProjectProposalRevision( - target: Partial & { orderByIdAsc?: boolean }, + target: Partial & { + orderByIdAsc?: boolean; + }, ): Promise { const { id, documentId, name, orderByIdAsc } = target; let query = this.db.select().from(ProjectProposalRevision).$dynamic(); diff --git a/packages/api/src/feature/proposal/project-proposal/service/project-proposal.service.ts b/packages/api/src/feature/proposal/project-proposal/service/project-proposal.service.ts index de78133..145cbe8 100644 --- a/packages/api/src/feature/proposal/project-proposal/service/project-proposal.service.ts +++ b/packages/api/src/feature/proposal/project-proposal/service/project-proposal.service.ts @@ -152,12 +152,12 @@ export class ProjectProposalService { body.organizationId, body.semesterId, ); - const count2ProjectProposal = await this.projectProposalRepository.selectProjectProposal({ organizationId: body.organizationId, semesterId: body.semesterId, }); + // ProjectProposal 생성이 제대로 되었는 지 확인 if (count1ProjectProposal.length + 1 !== count2ProjectProposal.length) { throw new HttpException(