diff --git a/src/common/errors/request.errors.js b/src/common/errors/request.errors.js index 821f3de..44f3939 100644 --- a/src/common/errors/request.errors.js +++ b/src/common/errors/request.errors.js @@ -53,4 +53,15 @@ export class StatusAlreadyChangedError extends BaseError { data, }); } +} + +export class RequestNotSubmittedError extends BaseError { + constructor(data = null) { + super({ + errorCode: "R006", + reason: "제출 상태가 아닌 리퀘스트입니다.", + statusCode: 400, + data, + }); + } } \ No newline at end of file diff --git a/src/common/swagger/request.json b/src/common/swagger/request.json index 1e23e8a..a8ea95f 100644 --- a/src/common/swagger/request.json +++ b/src/common/swagger/request.json @@ -570,6 +570,95 @@ } } } + }, + "/api/requests/{requestId}/result": { + "get": { + "tags": ["Request"], + "summary": "작업물 조회", + "description": "의뢰자가 제출된 커미션 작업물을 조회합니다. SUBMITTED 또는 COMPLETED 상태의 요청만 조회 가능합니다.", + "security": [{ "bearerAuth": [] }], + "parameters": [ + { + "name": "requestId", + "in": "path", + "required": true, + "schema": { "type": "integer", "format": "int64" }, + "description": "커미션 신청 ID" + } + ], + "responses": { + "200": { + "description": "작업물 조회 성공", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "resultType": { "type": "string", "example": "SUCCESS" }, + "error": { "type": "null", "example": null }, + "success": { + "type": "object", + "properties": { + "request": { + "type": "object", + "properties": { + "requestId": { "type": "integer", "example": 1 }, + "status": { + "type": "string", + "enum": ["SUBMITTED", "COMPLETED"], + "example": "SUBMITTED" + }, + "title": { "type": "string", "example": "낙서 타입 커미션" }, + "submittedAt": { + "type": "string", + "format": "date-time", + "example": "2025-06-04T15:30:00.000Z" + }, + "thumbnailImageUrl": { + "type": "string", + "nullable": true, + "example": "https://your-bucket.s3.amazonaws.com/commission/thumbnail.jpg" + }, + "commission": { + "type": "object", + "properties": { + "id": { "type": "integer", "example": 12 } + } + } + } + }, + "images": { + "type": "array", + "items": { "type": "string" }, + "example": [ + "https://example/result/image1.jpg" + ], + "description": "작업물 이미지 URL 목록" + } + } + } + } + } + } + } + }, + "400": { + "description": "작업물이 아직 제출되지 않음", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "resultType": { "type": "string", "example": "FAIL" }, + "error": { "type": "string", "example": "제출 상태가 아닌 리퀘스트입니다." }, + "success": { "type": "null", "example": null } + } + } + } + } + } + } + } } }, "components": { diff --git a/src/request/controller/request.controller.js b/src/request/controller/request.controller.js index 69ac00f..d079408 100644 --- a/src/request/controller/request.controller.js +++ b/src/request/controller/request.controller.js @@ -5,7 +5,8 @@ import { UpdateRequestStatusDto, GetRequestDetailDto, GetRequestFormDto, - GetCompletedRequestsDto + GetCompletedRequestsDto, + GetRequestResultDto } from "../dto/request.dto.js"; import { parseWithBigInt, stringifyWithBigInt } from "../../bigintJson.js"; @@ -97,4 +98,21 @@ export const getCompletedRequests = async (req, res, next) => { } catch (err) { next(err); } -}; \ No newline at end of file +}; + +// 작업물 조회 +export const getRequestResult = async (req, res, next) => { + try { + const userId = BigInt(req.user.userId); + const dto = new GetRequestResultDto({ + requestId: req.params.requestId + }); + + const result = await RequestService.getRequestResult(userId, dto); + const responseData = parseWithBigInt(stringifyWithBigInt(result)); + + res.status(StatusCodes.OK).success(responseData); + } catch (err) { + next(err); + } +}; diff --git a/src/request/dto/request.dto.js b/src/request/dto/request.dto.js index bedfbdf..a91c94e 100644 --- a/src/request/dto/request.dto.js +++ b/src/request/dto/request.dto.js @@ -37,4 +37,11 @@ export class GetCompletedRequestsDto { this.page = parseInt(page); this.limit = parseInt(limit); } +} + +// 작업물 조회 DTO +export class GetRequestResultDto { + constructor({ requestId }) { + this.requestId = BigInt(requestId); + } } \ No newline at end of file diff --git a/src/request/repository/request.repository.js b/src/request/repository/request.repository.js index 01c49b8..df86d35 100644 --- a/src/request/repository/request.repository.js +++ b/src/request/repository/request.repository.js @@ -303,5 +303,41 @@ async countCompletedRequestsByUserId(userId) { status: 'COMPLETED' } }); + }, + + /** + * Request 작업물 조회 + */ +async findRequestResultById(requestId) { + return await prisma.request.findUnique({ + where: { + id: BigInt(requestId) + }, + select: { + id: true, + userId: true, + status: true, + submittedAt: true, + commission: { + select: { + id: true, + title: true + } + } + } + }); +}, + +/** + * Request 작업물 이미지들 조회 + */ +async findResultImagesByRequestId(requestId) { + return await prisma.image.findMany({ + where: { + target: 'request-result', + targetId: BigInt(requestId) + }, + orderBy: { orderIndex: 'asc' } + }); } }; \ No newline at end of file diff --git a/src/request/request.routes.js b/src/request/request.routes.js index 9ea6ca7..a0dffff 100644 --- a/src/request/request.routes.js +++ b/src/request/request.routes.js @@ -4,7 +4,8 @@ import { getRequestDetail, updateRequestStatus, getSubmittedRequestForm, - getCompletedRequests + getCompletedRequests, + getRequestResult } from "./controller/request.controller.js"; import { authenticate } from "../middlewares/auth.middleware.js"; import reviewController from '../review/controller/review.controller.js'; @@ -21,6 +22,8 @@ router.get('/:requestId', authenticate, getRequestDetail); router.patch('/:requestId/status', authenticate, updateRequestStatus); // 제출된 신청서 조회 API router.get('/:requestId/forms', authenticate, getSubmittedRequestForm); +// 작업물 조회 API +router.get('/:requestId/result', authenticate, getRequestResult); /** * 리뷰 작성 API diff --git a/src/request/service/request.service.js b/src/request/service/request.service.js index 3a6af82..50bd91d 100644 --- a/src/request/service/request.service.js +++ b/src/request/service/request.service.js @@ -4,7 +4,8 @@ import { RequestNotFoundError, UnauthorizedRequestStatusChangeError, InvalidStatusTransitionError, - StatusAlreadyChangedError + StatusAlreadyChangedError, + RequestNotSubmittedError } from "../../common/errors/request.errors.js"; export const RequestService = { @@ -465,5 +466,50 @@ async getCompletedRequests(userId, dto) { totalPages } }; + }, + + /** + * 작업물 조회 + */ +async getRequestResult(userId, dto) { + const { requestId } = dto; + + // Request 존재 여부 확인 + const request = await RequestRepository.findRequestResultById(requestId); + if (!request) { + throw new RequestNotFoundError({ requestId }); + } + + // 권한 확인 (요청한 사용자가 해당 Request의 소유자인지) + if (request.userId !== BigInt(userId)) { + throw new UnauthorizedRequestStatusChangeError({ userId, requestId }); + } + + // 상태 확인 (SUBMITTED 또는 COMPLETED만 허용) + if (!['SUBMITTED', 'COMPLETED'].includes(request.status)) { + throw new RequestNotSubmittedError({ requestId, currentStatus: request.status }); + } + + // 작업물 이미지들 조회 + const resultImages = await RequestRepository.findResultImagesByRequestId(requestId); + const imageUrls = resultImages.map(image => image.imageUrl); + + // 커미션 썸네일 이미지 조회 + const thumbnailImages = await RequestRepository.findThumbnailImagesByCommissionIds([request.commission.id]); + const thumbnailImageUrl = thumbnailImages.length > 0 ? thumbnailImages[0].imageUrl : null; + + return { + request: { + requestId: request.id, + status: request.status, + title: request.commission.title, + submittedAt: request.submittedAt.toISOString(), + thumbnailImageUrl: thumbnailImageUrl, + commission: { + id: request.commission.id + } + }, + images: imageUrls + }; } }; \ No newline at end of file