diff --git a/coworkers-swagger.json b/coworkers-swagger.json new file mode 100644 index 0000000..63be435 --- /dev/null +++ b/coworkers-swagger.json @@ -0,0 +1,4761 @@ +{ + "openapi": "3.0.0", + "components": { + "examples": {}, + "headers": {}, + "parameters": {}, + "requestBodies": {}, + "responses": {}, + "schemas": { + "_36_Enums.Role": { + "type": "string", + "enum": ["ADMIN", "MEMBER"] + }, + "_36_Enums.FrequencyType": { + "type": "string", + "enum": ["DAILY", "WEEKLY", "MONTHLY", "ONCE"] + }, + "UrlType": { + "type": "string", + "example": "https://example.com/...", + "format": "url", + "pattern": "^https?://.+" + }, + "Email": { + "type": "string", + "example": "example@email.com", + "format": "email", + "pattern": "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$" + }, + "SendResetPasswordEmailRequest": { + "properties": { + "email": { + "$ref": "#/components/schemas/Email" + }, + "redirectUrl": { + "$ref": "#/components/schemas/UrlType" + } + }, + "required": ["email", "redirectUrl"], + "type": "object" + }, + "Password": { + "type": "string", + "example": "password", + "minLength": 8, + "pattern": "^([a-z]|[A-Z]|[0-9]|[!@#$%^&*])+$" + }, + "ResetPasswordBody": { + "properties": { + "passwordConfirmation": { + "$ref": "#/components/schemas/Password" + }, + "password": { + "$ref": "#/components/schemas/Password" + }, + "token": { + "type": "string" + } + }, + "required": ["passwordConfirmation", "password", "token"], + "type": "object" + }, + "UpdatePasswordBody": { + "properties": { + "passwordConfirmation": { + "$ref": "#/components/schemas/Password" + }, + "password": { + "$ref": "#/components/schemas/Password" + } + }, + "required": ["passwordConfirmation", "password"], + "type": "object" + }, + "Pick_UpdateUserBody.Exclude_keyofUpdateUserBody.encryptedPassword__": { + "properties": { + "nickname": { + "type": "string" + }, + "image": { + "type": "string" + } + }, + "type": "object", + "description": "From T, pick a set of properties whose keys are in the union K" + }, + "Omit_UpdateUserBody.encryptedPassword_": { + "$ref": "#/components/schemas/Pick_UpdateUserBody.Exclude_keyofUpdateUserBody.encryptedPassword__", + "description": "Construct a type with the properties of T except for those in type K." + }, + "FrequencyType": { + "type": "string", + "enum": ["DAILY", "WEEKLY", "MONTHLY", "ONCE"] + }, + "Task": { + "properties": { + "doneBy": { + "properties": { + "user": { + "properties": { + "image": { + "type": "string", + "nullable": true + }, + "nickname": { + "type": "string" + }, + "id": { + "type": "number", + "format": "double" + } + }, + "required": ["id"], + "type": "object", + "nullable": true + } + }, + "type": "object", + "nullable": true + }, + "writer": { + "properties": { + "image": { + "type": "string", + "nullable": true + }, + "nickname": { + "type": "string" + }, + "id": { + "type": "number", + "format": "double" + } + }, + "required": ["id"], + "type": "object", + "nullable": true + }, + "displayIndex": { + "type": "number", + "format": "double" + }, + "commentCount": { + "type": "number", + "format": "double" + }, + "deletedAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "recurringId": { + "type": "number", + "format": "double" + }, + "frequency": { + "$ref": "#/components/schemas/FrequencyType" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "doneAt": { + "type": "string", + "format": "date-time", + "nullable": true + }, + "date": { + "type": "string", + "format": "date-time" + }, + "description": { + "type": "string", + "nullable": true + }, + "name": { + "type": "string" + }, + "id": { + "type": "number", + "format": "double" + } + }, + "required": [ + "doneBy", + "displayIndex", + "commentCount", + "deletedAt", + "recurringId", + "frequency", + "updatedAt", + "doneAt", + "date", + "description", + "name", + "id" + ], + "type": "object" + }, + "DateString": { + "type": "string", + "example": "2021-01-01T00:00:00Z", + "format": "date-time" + }, + "Id": { + "type": "integer", + "format": "int32", + "minimum": 1 + }, + "DisplayIndex": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "MonthlyRecurringCreateBody": { + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "startDate": { + "$ref": "#/components/schemas/DateString" + }, + "frequencyType": { + "type": "string", + "enum": ["MONTHLY"], + "nullable": false + }, + "monthDay": { + "type": "number", + "format": "double" + } + }, + "required": ["name", "frequencyType", "monthDay"], + "type": "object", + "additionalProperties": false + }, + "WeeklyRecurringCreateBody": { + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "startDate": { + "$ref": "#/components/schemas/DateString" + }, + "frequencyType": { + "type": "string", + "enum": ["WEEKLY"], + "nullable": false + }, + "weekDays": { + "items": { + "type": "number", + "format": "double" + }, + "type": "array" + } + }, + "required": ["name", "frequencyType", "weekDays"], + "type": "object", + "additionalProperties": false + }, + "DailyRecurringCreateBody": { + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "startDate": { + "$ref": "#/components/schemas/DateString" + }, + "frequencyType": { + "type": "string", + "enum": ["DAILY"], + "nullable": false + } + }, + "required": ["name", "frequencyType"], + "type": "object", + "additionalProperties": false + }, + "OnceRecurringCreateBody": { + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "startDate": { + "$ref": "#/components/schemas/DateString" + }, + "frequencyType": { + "type": "string", + "enum": ["ONCE"], + "nullable": false + } + }, + "required": ["name", "frequencyType"], + "type": "object", + "additionalProperties": false + }, + "TaskRecurringCreateDto": { + "anyOf": [ + { + "$ref": "#/components/schemas/MonthlyRecurringCreateBody" + }, + { + "$ref": "#/components/schemas/WeeklyRecurringCreateBody" + }, + { + "$ref": "#/components/schemas/DailyRecurringCreateBody" + }, + { + "$ref": "#/components/schemas/OnceRecurringCreateBody" + } + ] + }, + "TaskUpdateDto": { + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "done": { + "type": "boolean" + } + }, + "type": "object", + "additionalProperties": false + }, + "TaskRecurringUpdateDto": { + "properties": { + "name": { + "type": "string" + }, + "description": { + "type": "string", + "nullable": true + }, + "startDate": { + "$ref": "#/components/schemas/DateString" + }, + "frequencyType": { + "$ref": "#/components/schemas/FrequencyType" + }, + "monthDay": { + "type": "number", + "format": "double" + }, + "weekDays": { + "items": { + "type": "number", + "format": "double" + }, + "type": "array" + } + }, + "type": "object", + "additionalProperties": false + }, + "_36_Enums.OauthProvider": { + "type": "string", + "enum": ["GOOGLE", "KAKAO"] + }, + "OauthProvider": { + "$ref": "#/components/schemas/_36_Enums.OauthProvider" + }, + "AppKey": { + "type": "string", + "description": "간편 로그인을 위한 인증 키 입니다.\n\n* Google 의 경우에는 \"클라이언트 id\" 입니다.\n* Kakao 의 경우에는 \"REST API 키\" 입니다.\n\n\n실습을 위해 발급받은 키를 등록해주세요. 실제 서비스에서 사용 하는 키는 등록하시면 안됩니다." + }, + "AppSecret": { + "type": "string", + "description": "간편 로그인을 위한 비밀 키 입니다.\n\n* Google 의 경우에는 필요하지 않습니다.\n* Kakao 의 경우에는 필요하지 않습니다." + }, + "OauthApp": { + "properties": { + "createdAt": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "appSecret": { + "allOf": [ + { + "$ref": "#/components/schemas/AppSecret" + } + ], + "nullable": true + }, + "appKey": { + "$ref": "#/components/schemas/AppKey" + }, + "provider": { + "$ref": "#/components/schemas/OauthProvider" + }, + "teamId": { + "type": "string" + }, + "id": { + "$ref": "#/components/schemas/Id" + } + }, + "required": ["createdAt", "updatedAt", "appSecret", "appKey", "provider", "teamId", "id"], + "type": "object" + }, + "UpsertOauthAppRequestBody": { + "properties": { + "appSecret": { + "$ref": "#/components/schemas/AppSecret" + }, + "appKey": { + "$ref": "#/components/schemas/AppKey" + }, + "provider": { + "$ref": "#/components/schemas/OauthProvider" + } + }, + "required": ["appKey", "provider"], + "type": "object" + }, + "Nickname": { + "type": "string", + "example": "Nickname", + "minLength": 1, + "maxLength": 30 + }, + "CreateGroupBody": { + "properties": { + "image": { + "$ref": "#/components/schemas/UrlType" + }, + "name": { + "$ref": "#/components/schemas/Nickname" + } + }, + "required": ["name"], + "type": "object" + }, + "GroupUpdateBody": { + "properties": { + "image": { + "allOf": [ + { + "$ref": "#/components/schemas/UrlType" + } + ], + "nullable": true + }, + "name": { + "$ref": "#/components/schemas/Nickname" + } + }, + "type": "object" + }, + "CreateCommentDto": { + "properties": { + "content": { + "type": "string" + } + }, + "required": ["content"], + "type": "object", + "additionalProperties": false + }, + "UpdateCommentDto": { + "properties": { + "content": { + "type": "string" + } + }, + "required": ["content"], + "type": "object", + "additionalProperties": false + }, + "User": { + "properties": { + "teamId": { + "type": "string" + }, + "image": { + "allOf": [ + { + "$ref": "#/components/schemas/UrlType" + } + ], + "nullable": true + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "nickname": { + "$ref": "#/components/schemas/Nickname" + }, + "id": { + "$ref": "#/components/schemas/Id" + } + }, + "required": ["teamId", "image", "updatedAt", "createdAt", "nickname", "id"], + "type": "object" + }, + "SignUpResponse": { + "properties": { + "refreshToken": { + "type": "string" + }, + "accessToken": { + "type": "string" + }, + "user": { + "allOf": [ + { + "$ref": "#/components/schemas/User" + }, + { + "properties": { + "email": { + "$ref": "#/components/schemas/Email" + } + }, + "required": ["email"], + "type": "object" + } + ] + } + }, + "required": ["refreshToken", "accessToken", "user"], + "type": "object" + }, + "SignUpRequestBody": { + "properties": { + "image": { + "$ref": "#/components/schemas/UrlType" + }, + "passwordConfirmation": { + "$ref": "#/components/schemas/Password" + }, + "password": { + "$ref": "#/components/schemas/Password" + }, + "nickname": { + "$ref": "#/components/schemas/Nickname" + }, + "email": { + "$ref": "#/components/schemas/Email" + } + }, + "required": ["passwordConfirmation", "password", "nickname", "email"], + "type": "object" + }, + "SignInResponse": { + "properties": { + "refreshToken": { + "type": "string" + }, + "accessToken": { + "type": "string" + }, + "user": { + "allOf": [ + { + "$ref": "#/components/schemas/User" + }, + { + "properties": { + "email": { + "$ref": "#/components/schemas/Email" + } + }, + "required": ["email"], + "type": "object" + } + ] + } + }, + "required": ["refreshToken", "accessToken", "user"], + "type": "object" + }, + "SignInRequestBody": { + "properties": { + "password": { + "$ref": "#/components/schemas/Password" + }, + "email": { + "$ref": "#/components/schemas/Email" + } + }, + "required": ["password", "email"], + "type": "object" + }, + "OauthToken": { + "type": "string", + "description": "간편 로그인 과정을 통해 발급받은 토큰입니다.
\n\n* Google 의 경우에는 Google Id 토큰(JWT) 입니다.\n* Kakao 의 경우에는 인가 코드 입니다." + }, + "SignInWithOauthRequestBody": { + "properties": { + "state": { + "type": "string", + "description": "code를 얻을 때 사용하였던 state 값을 그대로 사용합니다." + }, + "redirectUri": { + "type": "string", + "description": "Kakao 의 경우에는 필수입니다.
\n인가 코드를 얻을 때 사용하였던 redirect_uri 값을 그대로 사용합니다.", + "example": "http://localhost:3000/oauth/kakao" + }, + "token": { + "$ref": "#/components/schemas/OauthToken" + } + }, + "required": ["token"], + "type": "object" + }, + "CommentType": { + "properties": { + "writer": { + "properties": { + "image": { + "type": "string", + "nullable": true + }, + "nickname": { + "type": "string" + }, + "id": { + "$ref": "#/components/schemas/Id" + } + }, + "required": ["image", "nickname", "id"], + "type": "object" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "content": { + "type": "string" + }, + "id": { + "$ref": "#/components/schemas/Id" + } + }, + "required": ["writer", "updatedAt", "createdAt", "content", "id"], + "type": "object" + }, + "CreateCommentBody": { + "properties": { + "content": { + "type": "string", + "minLength": 1 + } + }, + "required": ["content"], + "type": "object" + }, + "CursorBasedPaginationResponse_CommentType_": { + "properties": { + "nextCursor": { + "type": "number", + "format": "double", + "nullable": true + }, + "list": { + "items": { + "$ref": "#/components/schemas/CommentType" + }, + "type": "array" + } + }, + "required": ["nextCursor", "list"], + "type": "object" + }, + "UpdateCommentBody": { + "properties": { + "content": { + "type": "string", + "minLength": 1 + } + }, + "type": "object" + }, + "ArticleTitle": { + "type": "string", + "example": "게시글 제목입니다.", + "minLength": 1, + "maxLength": 200 + }, + "ArticleContent": { + "type": "string", + "example": "게시글 내용입니다.", + "minLength": 1 + }, + "ArticleWriter": { + "properties": { + "nickname": { + "type": "string" + }, + "id": { + "$ref": "#/components/schemas/Id" + } + }, + "required": ["nickname", "id"], + "type": "object" + }, + "ArticleListType": { + "properties": { + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "commentCount": { + "type": "number", + "format": "double" + }, + "likeCount": { + "type": "number", + "format": "double" + }, + "writer": { + "$ref": "#/components/schemas/ArticleWriter" + }, + "image": { + "allOf": [ + { + "$ref": "#/components/schemas/UrlType" + } + ], + "nullable": true + }, + "content": { + "$ref": "#/components/schemas/ArticleContent" + }, + "title": { + "$ref": "#/components/schemas/ArticleTitle" + }, + "id": { + "$ref": "#/components/schemas/Id" + } + }, + "required": [ + "updatedAt", + "createdAt", + "commentCount", + "likeCount", + "writer", + "image", + "content", + "title", + "id" + ], + "type": "object" + }, + "CreateArticleBody": { + "properties": { + "image": { + "$ref": "#/components/schemas/UrlType" + }, + "content": { + "$ref": "#/components/schemas/ArticleContent" + }, + "title": { + "$ref": "#/components/schemas/ArticleTitle" + } + }, + "required": ["content", "title"], + "type": "object" + }, + "OffsetBasedPaginationResponse_ArticleListType_": { + "properties": { + "totalCount": { + "type": "number", + "format": "double" + }, + "list": { + "items": { + "$ref": "#/components/schemas/ArticleListType" + }, + "type": "array" + } + }, + "required": ["totalCount", "list"], + "type": "object" + }, + "ListArticleOrder": { + "type": "string", + "enum": ["recent", "like"], + "default": "recent" + }, + "ArticleDetailType": { + "allOf": [ + { + "$ref": "#/components/schemas/ArticleListType" + }, + { + "properties": { + "isLiked": { + "type": "boolean", + "nullable": true + } + }, + "required": ["isLiked"], + "type": "object" + } + ] + }, + "UpdateArticleBody": { + "properties": { + "image": { + "allOf": [ + { + "$ref": "#/components/schemas/UrlType" + } + ], + "nullable": true + }, + "content": { + "$ref": "#/components/schemas/ArticleContent" + }, + "title": { + "$ref": "#/components/schemas/ArticleTitle" + } + }, + "type": "object" + } + }, + "securitySchemes": { + "jwt": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + } + }, + "info": { + "title": "coworkers-api", + "version": "1.0.0", + "license": { + "name": "ISC" + }, + "contact": {} + }, + "paths": { + "/{teamId}/user": { + "get": { + "operationId": "GetUser", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "properties": { + "teamId": { + "type": "string" + }, + "image": { + "type": "string" + }, + "nickname": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "email": { + "type": "string" + }, + "id": { + "type": "number", + "format": "double" + }, + "memberships": { + "items": { + "allOf": [ + { + "properties": { + "group": { + "properties": { + "teamId": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "image": { + "type": "string" + }, + "name": { + "type": "string" + }, + "id": { + "type": "number", + "format": "double" + } + }, + "required": [ + "teamId", + "updatedAt", + "createdAt", + "image", + "name", + "id" + ], + "type": "object" + } + }, + "required": ["group"], + "type": "object" + }, + { + "properties": { + "role": { + "$ref": "#/components/schemas/_36_Enums.Role" + }, + "userImage": { + "type": "string" + }, + "userEmail": { + "type": "string" + }, + "userName": { + "type": "string" + }, + "groupId": { + "type": "number", + "format": "double" + }, + "userId": { + "type": "number", + "format": "double" + } + }, + "required": [ + "role", + "userImage", + "userEmail", + "userName", + "groupId", + "userId" + ], + "type": "object" + } + ] + }, + "type": "array" + } + }, + "required": [ + "teamId", + "image", + "nickname", + "updatedAt", + "createdAt", + "email", + "id", + "memberships" + ], + "type": "object" + } + } + } + } + }, + "tags": ["User"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + } + ] + }, + "patch": { + "operationId": "UpdateMe", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "type": "object" + } + } + } + } + }, + "tags": ["User"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Omit_UpdateUserBody.encryptedPassword_" + } + } + } + } + }, + "delete": { + "operationId": "DeleteMe", + "responses": { + "204": { + "description": "No content" + } + }, + "description": "회원 탈퇴", + "tags": ["User"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, + "/{teamId}/user/groups": { + "get": { + "operationId": "GetGroups", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "items": { + "properties": { + "teamId": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "image": { + "type": "string" + }, + "name": { + "type": "string" + }, + "id": { + "type": "number", + "format": "double" + } + }, + "required": ["teamId", "updatedAt", "createdAt", "image", "name", "id"], + "type": "object" + }, + "type": "array" + } + } + } + } + }, + "tags": ["User"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, + "/{teamId}/user/memberships": { + "get": { + "operationId": "GetMemberships", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "items": { + "allOf": [ + { + "properties": { + "group": { + "properties": { + "teamId": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "image": { + "type": "string" + }, + "name": { + "type": "string" + }, + "id": { + "type": "number", + "format": "double" + } + }, + "required": ["teamId", "updatedAt", "createdAt", "image", "name", "id"], + "type": "object" + } + }, + "required": ["group"], + "type": "object" + }, + { + "properties": { + "role": { + "$ref": "#/components/schemas/_36_Enums.Role" + }, + "userImage": { + "type": "string" + }, + "userEmail": { + "type": "string" + }, + "userName": { + "type": "string" + }, + "groupId": { + "type": "number", + "format": "double" + }, + "userId": { + "type": "number", + "format": "double" + } + }, + "required": [ + "role", + "userImage", + "userEmail", + "userName", + "groupId", + "userId" + ], + "type": "object" + } + ] + }, + "type": "array" + } + } + } + } + }, + "tags": ["User"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, + "/{teamId}/user/history": { + "get": { + "operationId": "ListDoneTasks", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "properties": { + "tasksDone": { + "items": { + "properties": { + "displayIndex": { + "type": "number", + "format": "double" + }, + "writerId": { + "type": "number", + "format": "double" + }, + "userId": { + "type": "number", + "format": "double" + }, + "deletedAt": { + "type": "string", + "format": "date-time" + }, + "frequency": { + "$ref": "#/components/schemas/_36_Enums.FrequencyType" + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "recurringId": { + "type": "number", + "format": "double" + }, + "doneAt": { + "type": "string", + "format": "date-time" + }, + "date": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "number", + "format": "double" + } + }, + "required": [ + "displayIndex", + "writerId", + "userId", + "deletedAt", + "frequency", + "description", + "name", + "recurringId", + "doneAt", + "date", + "updatedAt", + "id" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": ["tasksDone"], + "type": "object" + } + } + } + } + }, + "description": "완료한 작업 조회", + "tags": ["User"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, + "/{teamId}/user/send-reset-password-email": { + "post": { + "operationId": "SendResetPasswordEmail", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "type": "object" + } + } + } + } + }, + "description": "비밀번호 재설정 이메일 전송\n- {redirectUrl}/reset-password?token=${token}로 이동할 수 있는 링크를 이메일로 전송합니다.\n e.g. \"https://coworkers.vercel.app/reset-password?token=1234567890\"", + "tags": ["User"], + "security": [], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "\"email\": \"example@email.com\",\n\"redirectUrl\": \"http://localhost:3000\"\n}", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SendResetPasswordEmailRequest", + "description": "\"email\": \"example@email.com\",\n\"redirectUrl\": \"http://localhost:3000\"\n}" + } + } + } + } + } + }, + "/{teamId}/user/reset-password": { + "patch": { + "operationId": "ResetPassword", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "type": "object" + } + } + } + } + }, + "description": "이메일로 전달받은 링크에서 비밀번호 초기화\n- POST user/send-reset-password-email 요청으로 발송한 메일의 링크에 담긴 토큰을 사용해야 합니다.", + "tags": ["User"], + "security": [], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ResetPasswordBody" + } + } + } + } + } + }, + "/{teamId}/user/password": { + "patch": { + "operationId": "UpdatePassword", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "type": "object" + } + } + } + } + }, + "tags": ["User"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdatePasswordBody" + } + } + } + } + } + }, + "/{teamId}/groups/{groupId}/task-lists/{id}": { + "get": { + "operationId": "GetTaskList", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "properties": { + "displayIndex": { + "type": "number", + "format": "double" + }, + "groupId": { + "type": "number", + "format": "double" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "name": { + "type": "string" + }, + "id": { + "type": "number", + "format": "double" + }, + "tasks": { + "items": { + "$ref": "#/components/schemas/Task" + }, + "type": "array" + } + }, + "required": [ + "displayIndex", + "groupId", + "updatedAt", + "createdAt", + "name", + "id", + "tasks" + ], + "type": "object" + } + } + } + } + }, + "tags": ["TaskList"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "date", + "required": false, + "schema": { + "$ref": "#/components/schemas/DateString" + } + } + ] + }, + "patch": { + "operationId": "UpdateTaskList", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "properties": { + "displayIndex": { + "type": "number", + "format": "double" + }, + "groupId": { + "type": "number", + "format": "double" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "name": { + "type": "string" + }, + "id": { + "type": "number", + "format": "double" + } + }, + "required": ["displayIndex", "groupId", "updatedAt", "createdAt", "name", "id"], + "type": "object" + } + } + } + } + }, + "tags": ["TaskList"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "groupId", + "required": true, + "schema": { + "$ref": "#/components/schemas/Id" + } + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "properties": { + "name": { + "type": "string" + } + }, + "type": "object" + } + } + } + } + }, + "delete": { + "operationId": "DeleteTaskList", + "responses": { + "204": { + "description": "No content" + } + }, + "tags": ["TaskList"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, + "/{teamId}/groups/{groupId}/task-lists": { + "post": { + "operationId": "CreateTaskList", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "properties": { + "displayIndex": { + "type": "number", + "format": "double" + }, + "groupId": { + "type": "number", + "format": "double" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "name": { + "type": "string" + }, + "id": { + "type": "number", + "format": "double" + } + }, + "required": ["displayIndex", "groupId", "updatedAt", "createdAt", "name", "id"], + "type": "object" + } + } + } + } + }, + "tags": ["TaskList"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "groupId", + "required": true, + "schema": { + "$ref": "#/components/schemas/Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"], + "type": "object" + } + } + } + } + } + }, + "/{teamId}/groups/{groupId}/task-lists/{id}/order": { + "patch": { + "operationId": "UpdateTaskListOrder", + "responses": { + "204": { + "description": "No content" + } + }, + "description": "할일 목록의 순서를 변경합니다.", + "tags": ["TaskList"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "groupId", + "required": true, + "schema": { + "$ref": "#/components/schemas/Id" + } + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/Id" + } + } + ], + "requestBody": { + "description": "taskList의 displayIndex를 변경합니다. 해당 taskList가 기존 displayIndex를 버리고 넘어가면서, 그 빈 displayIndex는 \"한 자리씩 당겨지는 식\"으로 변경됩니다. [1,2,3,4] => (3을 0 인덱스로) => [3,1,2,4] => (4를 1 인덱스로) => [3,4,1,2]", + "required": true, + "content": { + "application/json": { + "schema": { + "properties": { + "displayIndex": { + "$ref": "#/components/schemas/DisplayIndex" + } + }, + "required": ["displayIndex"], + "type": "object", + "description": "taskList의 displayIndex를 변경합니다. 해당 taskList가 기존 displayIndex를 버리고 넘어가면서, 그 빈 displayIndex는 \"한 자리씩 당겨지는 식\"으로 변경됩니다. [1,2,3,4] => (3을 0 인덱스로) => [3,1,2,4] => (4를 1 인덱스로) => [3,4,1,2]" + } + } + } + } + } + }, + "/{teamId}/groups/{groupId}/task-lists/{taskListId}/tasks": { + "post": { + "operationId": "CreateRecurringTasks", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "properties": { + "recurring": { + "properties": { + "writerId": { + "type": "number", + "format": "double" + }, + "groupId": { + "type": "number", + "format": "double" + }, + "taskListId": { + "type": "number", + "format": "double" + }, + "monthDay": { + "type": "number", + "format": "double" + }, + "weekDays": { + "items": { + "type": "number", + "format": "double" + }, + "type": "array" + }, + "frequencyType": { + "$ref": "#/components/schemas/_36_Enums.FrequencyType" + }, + "startDate": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "id": { + "type": "number", + "format": "double" + } + }, + "required": [ + "writerId", + "groupId", + "taskListId", + "monthDay", + "weekDays", + "frequencyType", + "startDate", + "updatedAt", + "createdAt", + "description", + "name", + "id" + ], + "type": "object" + } + }, + "required": ["recurring"], + "type": "object" + } + } + } + } + }, + "description": "DEPRECATED: 여전히 동작합니다. 새로운 반복일정 생성 API를 제공합니다. \\\n아래 /{teamId}/groups/{groupId}/task-lists/{taskListId}/recurring 를 참고하세요\n\n(반복)일정을 생성합니다.\\\n일종의 정책으로, 반복정책을 정하면, 해당 정책에 따라 할일이 생성됩니다.\n\n할일(task)는 반복일정에 지정한 frequencyType에 따라 다르게 생성됩니다. \\\nONCE: 한 번만 생성 (해당 일 조회시, 할일 존재) \\\nDAILY: 매일 생성 (시작일(startDate) 이후 어느 날짜를 조회해도 존재함) \\\nWEEKLY: 매주 생성 (시작일(startDate) 이후 해당 조건에 따라 존재) \\\nMONTHLY: 매월 생성 (시작일(startDate) 이후 해당 조건에 따라 존재)", + "tags": ["Task"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "groupId", + "required": true, + "schema": { + "format": "double", + "type": "number" + } + }, + { + "in": "path", + "name": "taskListId", + "required": true, + "schema": { + "format": "double", + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskRecurringCreateDto" + } + } + } + } + }, + "get": { + "operationId": "ListTasks", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/Task" + }, + "type": "array" + } + } + } + } + }, + "description": "특정 일자, 특정 할일 리스트의 할일 리스트", + "tags": ["Task"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "groupId", + "required": true, + "schema": { + "format": "double", + "type": "number" + } + }, + { + "in": "path", + "name": "taskListId", + "required": true, + "schema": { + "format": "double", + "type": "number" + } + }, + { + "in": "query", + "name": "date", + "required": false, + "schema": { + "$ref": "#/components/schemas/DateString" + } + } + ] + } + }, + "/{teamId}/groups/{groupId}/task-lists/{taskListId}/tasks/{taskId}": { + "get": { + "operationId": "GetTask", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Task" + } + } + } + } + }, + "tags": ["Task"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "taskId", + "required": true, + "schema": { + "format": "double", + "type": "number" + } + } + ] + }, + "patch": { + "operationId": "UpdateTask", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "properties": { + "displayIndex": { + "type": "number", + "format": "double" + }, + "writerId": { + "type": "number", + "format": "double" + }, + "userId": { + "type": "number", + "format": "double" + }, + "deletedAt": { + "type": "string", + "format": "date-time" + }, + "frequency": { + "$ref": "#/components/schemas/_36_Enums.FrequencyType" + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "recurringId": { + "type": "number", + "format": "double" + }, + "doneAt": { + "type": "string", + "format": "date-time" + }, + "date": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "number", + "format": "double" + } + }, + "required": [ + "displayIndex", + "writerId", + "userId", + "deletedAt", + "frequency", + "description", + "name", + "recurringId", + "doneAt", + "date", + "updatedAt", + "id" + ], + "type": "object" + } + } + } + } + }, + "tags": ["Task"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "taskId", + "required": true, + "schema": { + "format": "double", + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskUpdateDto" + } + } + } + } + }, + "delete": { + "operationId": "DeleteTask", + "responses": { + "204": { + "description": "No content" + } + }, + "description": "특정 할일 삭제", + "tags": ["Task"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "할일 id", + "in": "path", + "name": "taskId", + "required": true, + "schema": { + "format": "double", + "type": "number" + } + } + ] + } + }, + "/{teamId}/groups/{groupId}/task-lists/{taskListId}/tasks/{id}/order": { + "patch": { + "operationId": "UpdateTaskOrder", + "responses": { + "204": { + "description": "No content" + } + }, + "tags": ["Task"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "taskListId", + "required": true, + "schema": { + "format": "double", + "type": "number" + } + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "double", + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "properties": { + "displayIndex": { + "$ref": "#/components/schemas/DisplayIndex" + } + }, + "required": ["displayIndex"], + "type": "object" + } + } + } + } + } + }, + "/{teamId}/groups/{groupId}/task-lists/{taskListId}/tasks/{taskId}/recurring/{recurringId}": { + "delete": { + "operationId": "DeleteRecurringTask", + "responses": { + "204": { + "description": "No content" + } + }, + "description": "반복할일 삭제", + "tags": ["Task"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "반복할일 id (task 객체의 recurringId 필드, 반복설정으로 생성된 할일이 아닌, 반복설정 자체를 삭제)", + "in": "path", + "name": "recurringId", + "required": true, + "schema": { + "format": "double", + "type": "number" + } + } + ] + } + }, + "/{teamId}/groups/{groupId}/task-lists/{taskListId}/recurring": { + "post": { + "operationId": "CreateRecurringTasks", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "properties": { + "writerId": { + "type": "number", + "format": "double" + }, + "groupId": { + "type": "number", + "format": "double" + }, + "taskListId": { + "type": "number", + "format": "double" + }, + "monthDay": { + "type": "number", + "format": "double" + }, + "weekDays": { + "items": { + "type": "number", + "format": "double" + }, + "type": "array" + }, + "frequencyType": { + "$ref": "#/components/schemas/_36_Enums.FrequencyType" + }, + "startDate": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "id": { + "type": "number", + "format": "double" + } + }, + "required": [ + "writerId", + "groupId", + "taskListId", + "monthDay", + "weekDays", + "frequencyType", + "startDate", + "updatedAt", + "createdAt", + "description", + "name", + "id" + ], + "type": "object" + } + } + } + } + }, + "description": "(반복)일정을 생성합니다.\\\n일종의 정책으로, 반복정책을 정하면, 해당 정책에 따라 할일이 생성됩니다.\n\n할일(task)는 반복일정에 지정한 frequencyType에 따라 다르게 생성됩니다. \\\nONCE: 한 번만 생성 (해당 일 조회시, 할일 존재) \\\nDAILY: 매일 생성 (시작일(startDate) 이후 어느 날짜를 조회해도 존재함) \\\nWEEKLY: 매주 생성 (시작일(startDate) 이후 해당 조건에 따라 존재) \\\nMONTHLY: 매월 생성 (시작일(startDate) 이후 해당 조건에 따라 존재)", + "tags": ["Recurring"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "groupId", + "required": true, + "schema": { + "format": "double", + "type": "number" + } + }, + { + "in": "path", + "name": "taskListId", + "required": true, + "schema": { + "format": "double", + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskRecurringCreateDto" + } + } + } + } + } + }, + "/{teamId}/groups/{groupId}/task-lists/{taskListId}/recurring/{recurringId}": { + "patch": { + "operationId": "UpdateRecurringTasks", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "properties": { + "writerId": { + "type": "number", + "format": "double" + }, + "groupId": { + "type": "number", + "format": "double" + }, + "taskListId": { + "type": "number", + "format": "double" + }, + "monthDay": { + "type": "number", + "format": "double" + }, + "weekDays": { + "items": { + "type": "number", + "format": "double" + }, + "type": "array" + }, + "frequencyType": { + "$ref": "#/components/schemas/_36_Enums.FrequencyType" + }, + "startDate": { + "type": "string", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + }, + "id": { + "type": "number", + "format": "double" + } + }, + "required": [ + "writerId", + "groupId", + "taskListId", + "monthDay", + "weekDays", + "frequencyType", + "startDate", + "updatedAt", + "createdAt", + "description", + "name", + "id" + ], + "type": "object" + } + } + } + } + }, + "description": "반복 일정을 수정합니다.\\\n반복 일정의 이름, 설명, 시작 날짜, 빈도 타입 등을 수정할 수 있습니다.", + "tags": ["Recurring"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "groupId", + "required": true, + "schema": { + "format": "double", + "type": "number" + } + }, + { + "in": "path", + "name": "taskListId", + "required": true, + "schema": { + "format": "double", + "type": "number" + } + }, + { + "description": "수정할 반복 일정의 ID", + "in": "path", + "name": "recurringId", + "required": true, + "schema": { + "format": "double", + "type": "number" + } + } + ], + "requestBody": { + "description": "수정할 정보 (모든 필드는 선택사항)", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TaskRecurringUpdateDto", + "description": "수정할 정보 (모든 필드는 선택사항)" + } + } + } + } + } + }, + "/{teamId}/oauthApps": { + "post": { + "operationId": "UpsertOauthApp", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OauthApp" + } + } + } + } + }, + "description": "간편 로그인 App 등록/수정
\nGoogle, Kakao 간편 로그인을 위한 App 을 등록하거나 수정합니다.
\n이미 등록된 앱이 있을 경우 덮어씌워집니다.\n\n## Google\n* appKey: \"클라이언트 id\"\n* appSecret: 필요하지 않음\n\n---\n\n## Kakao\n* appKey: \"REST API 키\"\n* appSecret: 필요하지 않음\n\n---\n\n실습을 위해 발급받은 키를 등록해주세요. 실제 서비스에서 사용 하는 키를 등록해서는 안됩니다.", + "tags": ["Oauth"], + "security": [], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertOauthAppRequestBody" + } + } + } + } + } + }, + "/{teamId}/images/upload": { + "post": { + "operationId": "ImageUpload", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "properties": { + "url": { + "type": "string" + } + }, + "required": ["url"], + "type": "object" + } + } + } + } + }, + "description": "이미지 업로드,\n프로젝트에 저장하는 이미지들은 이 엔드포인트를 통해 업로드한 후 URL을 획득하여 사용합니다.", + "tags": ["Image"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "image": { + "type": "string", + "format": "binary", + "description": "이미지 파일, 최대 용량은 10MB입니다." + } + }, + "required": ["image"] + } + } + } + } + } + }, + "/{teamId}/groups/{id}": { + "get": { + "operationId": "Get", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "properties": { + "teamId": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "image": { + "type": "string" + }, + "name": { + "type": "string" + }, + "id": { + "type": "number", + "format": "double" + }, + "members": { + "items": { + "properties": { + "role": { + "$ref": "#/components/schemas/_36_Enums.Role" + }, + "userImage": { + "type": "string" + }, + "userEmail": { + "type": "string" + }, + "userName": { + "type": "string" + }, + "groupId": { + "type": "number", + "format": "double" + }, + "userId": { + "type": "number", + "format": "double" + } + }, + "required": [ + "role", + "userImage", + "userEmail", + "userName", + "groupId", + "userId" + ], + "type": "object" + }, + "type": "array" + }, + "taskLists": { + "items": { + "properties": { + "displayIndex": { + "type": "number", + "format": "double" + }, + "groupId": { + "type": "number", + "format": "double" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "name": { + "type": "string" + }, + "id": { + "type": "number", + "format": "double" + }, + "tasks": { + "items": {}, + "type": "array" + } + }, + "required": [ + "displayIndex", + "groupId", + "updatedAt", + "createdAt", + "name", + "id", + "tasks" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "teamId", + "updatedAt", + "createdAt", + "image", + "name", + "id", + "members", + "taskLists" + ], + "type": "object" + } + } + } + } + }, + "tags": ["Group"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "double", + "type": "number" + } + } + ] + }, + "patch": { + "operationId": "Update", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "properties": { + "teamId": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "image": { + "type": "string" + }, + "name": { + "type": "string" + }, + "id": { + "type": "number", + "format": "double" + } + }, + "required": ["teamId", "updatedAt", "createdAt", "image", "name", "id"], + "type": "object" + } + } + } + } + }, + "tags": ["Group"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "double", + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GroupUpdateBody" + } + } + } + } + }, + "delete": { + "operationId": "Delete", + "responses": { + "204": { + "description": "No content" + } + }, + "tags": ["Group"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "double", + "type": "number" + } + } + ] + } + }, + "/{teamId}/groups": { + "post": { + "operationId": "Create", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "properties": { + "name": { + "type": "string" + }, + "image": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "number", + "format": "double" + } + }, + "required": ["name", "image", "updatedAt", "createdAt", "id"], + "type": "object" + } + } + } + } + }, + "tags": ["Group"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateGroupBody" + } + } + } + } + } + }, + "/{teamId}/groups/{id}/member/{memberUserId}": { + "get": { + "operationId": "GetMember", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "properties": { + "role": { + "$ref": "#/components/schemas/_36_Enums.Role" + }, + "userImage": { + "type": "string" + }, + "userEmail": { + "type": "string" + }, + "userName": { + "type": "string" + }, + "groupId": { + "type": "number", + "format": "double" + }, + "userId": { + "type": "number", + "format": "double" + } + }, + "required": ["role", "userImage", "userEmail", "userName", "groupId", "userId"], + "type": "object" + } + } + } + } + }, + "description": "그룹에 소속된 유저 조회\n그룹 조회(GET /groups/:id)시, 멤버로 가입된 유저 목록도 함께 조회됨.", + "tags": ["Group"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/Id" + } + }, + { + "in": "path", + "name": "memberUserId", + "required": true, + "schema": { + "$ref": "#/components/schemas/Id" + } + } + ] + }, + "delete": { + "operationId": "DeleteMember", + "responses": { + "204": { + "description": "No content" + } + }, + "tags": ["Group"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/Id" + } + }, + { + "in": "path", + "name": "memberUserId", + "required": true, + "schema": { + "$ref": "#/components/schemas/Id" + } + } + ] + } + }, + "/{teamId}/groups/{id}/invitation": { + "get": { + "operationId": "GetInvitation", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": {} + } + } + } + }, + "description": "초대 링크용 토큰 생성\n- 초대 링크에 토큰을 포함시켜서, 초대 링크를 받은 사용자가 접속시, 토큰을 사용해서 초대를 수락하여 스스로를 그룹에 포함시키게 됨.", + "tags": ["Group"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "double", + "type": "number" + } + } + ] + } + }, + "/{teamId}/groups/accept-invitation": { + "post": { + "operationId": "AcceptInvitation", + "responses": { + "200": { + "description": "초대를 수락한 그룹의 id", + "content": { + "application/json": { + "schema": { + "properties": { + "groupId": { + "type": "number", + "format": "double" + } + }, + "required": ["groupId"], + "type": "object" + } + } + } + } + }, + "description": "- GET {id}/invitation으로 생성한 토큰으로, 초대를 수락하는 엔드포인트", + "tags": ["Group"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "token은 초대 링크에 포함되어있는 토큰, userEmail은 초대를 수락하는 유저의 이메일", + "required": true, + "content": { + "application/json": { + "schema": { + "properties": { + "userEmail": { + "type": "string" + }, + "token": { + "type": "string" + } + }, + "required": ["userEmail", "token"], + "type": "object", + "description": "token은 초대 링크에 포함되어있는 토큰, userEmail은 초대를 수락하는 유저의 이메일" + } + } + } + } + } + }, + "/{teamId}/groups/{id}/member": { + "post": { + "operationId": "InviteMember", + "responses": { + "204": { + "description": "No content" + } + }, + "description": "초대 링크없이 그룹에 유저를 추가하는 엔드포인트", + "tags": ["Group"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "double", + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "properties": { + "userEmail": { + "type": "string" + } + }, + "required": ["userEmail"], + "type": "object" + } + } + } + } + } + }, + "/{teamId}/groups/{id}/tasks": { + "get": { + "operationId": "ListGroupTasks", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/Task" + }, + "type": "array" + } + } + } + } + }, + "description": "특정 일자, 특정 할일 리스트의 할일 리스트", + "tags": ["Group"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "$ref": "#/components/schemas/Id" + } + }, + { + "in": "query", + "name": "date", + "required": false, + "schema": { + "$ref": "#/components/schemas/DateString" + } + } + ] + } + }, + "/{teamId}/tasks/{taskId}/comments": { + "get": { + "operationId": "GetByTaskId", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "items": { + "allOf": [ + { + "properties": { + "user": { + "properties": { + "image": { + "type": "string" + }, + "nickname": { + "type": "string" + }, + "id": { + "type": "number", + "format": "double" + } + }, + "required": ["image", "nickname", "id"], + "type": "object" + } + }, + "required": ["user"], + "type": "object" + }, + { + "properties": { + "userId": { + "type": "number", + "format": "double" + }, + "taskId": { + "type": "number", + "format": "double" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "content": { + "type": "string" + }, + "id": { + "type": "number", + "format": "double" + } + }, + "required": ["userId", "taskId", "updatedAt", "createdAt", "content", "id"], + "type": "object" + } + ] + }, + "type": "array" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "number", + "enum": [404], + "nullable": false + }, + "examples": { + "Example 1": {} + } + } + }, + "headers": { + "message": { + "schema": { + "type": "string" + }, + "required": true + } + } + } + }, + "tags": ["Comment"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "taskId", + "required": true, + "schema": { + "$ref": "#/components/schemas/Id" + } + } + ] + }, + "post": { + "operationId": "PostComment", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "properties": { + "content": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "number", + "format": "double" + }, + "user": { + "properties": { + "image": { + "type": "string" + }, + "nickname": { + "type": "string" + }, + "id": { + "type": "number", + "format": "double" + } + }, + "required": ["image", "nickname", "id"], + "type": "object" + } + }, + "required": ["content", "updatedAt", "createdAt", "id", "user"], + "type": "object" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "number", + "enum": [404], + "nullable": false + }, + "examples": { + "Example 1": {} + } + } + }, + "headers": { + "message": { + "schema": { + "type": "string" + }, + "required": true + } + } + } + }, + "tags": ["Comment"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "taskId", + "required": true, + "schema": { + "$ref": "#/components/schemas/Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCommentDto" + } + } + } + } + } + }, + "/{teamId}/tasks/{taskId}/comments/{commentId}": { + "patch": { + "operationId": "UpdateComment", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "properties": { + "content": { + "type": "string" + }, + "updatedAt": { + "type": "string", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "id": { + "type": "number", + "format": "double" + }, + "user": { + "properties": { + "image": { + "type": "string" + }, + "nickname": { + "type": "string" + }, + "id": { + "type": "number", + "format": "double" + } + }, + "required": ["image", "nickname", "id"], + "type": "object" + } + }, + "required": ["content", "updatedAt", "createdAt", "id", "user"], + "type": "object" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "number", + "enum": [403], + "nullable": false + }, + "examples": { + "Example 1": {} + } + } + }, + "headers": { + "message": { + "schema": { + "type": "string" + }, + "required": true + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "number", + "enum": [404], + "nullable": false + }, + "examples": { + "Example 1": {} + } + } + }, + "headers": { + "message": { + "schema": { + "type": "string" + }, + "required": true + } + } + } + }, + "tags": ["Comment"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "commentId", + "required": true, + "schema": { + "$ref": "#/components/schemas/Id" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCommentDto" + } + } + } + } + }, + "delete": { + "operationId": "DeleteComment", + "responses": { + "204": { + "description": "No content" + }, + "403": { + "description": "댓글 작성자만 삭제할 수 있습니다.", + "content": { + "application/json": { + "schema": { + "type": "number", + "enum": [403], + "nullable": false + }, + "examples": { + "Example 1": {} + } + } + }, + "headers": { + "message": { + "schema": { + "type": "string" + }, + "required": true + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "number", + "enum": [404], + "nullable": false + }, + "examples": { + "Example 1": {} + } + } + }, + "headers": { + "message": { + "schema": { + "type": "string" + }, + "required": true + } + } + } + }, + "tags": ["Comment"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "commentId", + "required": true, + "schema": { + "$ref": "#/components/schemas/Id" + } + } + ] + } + }, + "/{teamId}/auth/signUp": { + "post": { + "operationId": "SignUp", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SignUpResponse" + }, + "examples": { + "Example 1": { + "value": { + "accessToken": "accessToken", + "refreshToken": "refreshToken", + "user": { + "id": 123, + "email": "example@email.com", + "nickname": "example", + "updatedAt": "2026-02-18T14:41:25.683Z", + "createdAt": "2026-02-18T14:41:25.683Z", + "image": null, + "teamId": "teamId" + } + } + } + } + } + } + } + }, + "description": "회원가입", + "tags": ["Auth"], + "security": [], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SignUpRequestBody" + }, + "example": { + "email": "example@email.com", + "nickname": "nickname", + "password": "password", + "passwordConfirmation": "password" + } + } + } + } + } + }, + "/{teamId}/auth/signIn": { + "post": { + "operationId": "SignIn", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SignInResponse" + }, + "examples": { + "Example 1": { + "value": { + "accessToken": "accessToken", + "refreshToken": "refresh", + "user": { + "id": 123, + "email": "example@email.com", + "nickname": "example", + "updatedAt": "2026-02-18T14:41:25.685Z", + "createdAt": "2026-02-18T14:41:25.685Z", + "image": null, + "teamId": "teamId" + } + } + } + } + } + } + } + }, + "description": "로그인", + "tags": ["Auth"], + "security": [], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SignInRequestBody" + }, + "example": { + "email": "example@email.com", + "password": "password" + } + } + } + } + } + }, + "/{teamId}/auth/refresh-token": { + "post": { + "operationId": "RefreshToken", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "properties": { + "accessToken": { + "type": "string" + } + }, + "required": ["accessToken"], + "type": "object" + } + } + } + } + }, + "description": "액세스토큰 새로 받기", + "tags": ["Auth"], + "security": [], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "properties": { + "refreshToken": { + "type": "string" + } + }, + "required": ["refreshToken"], + "type": "object" + } + } + } + } + } + }, + "/{teamId}/auth/signIn/{provider}": { + "post": { + "operationId": "SignInWithOauth", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SignInResponse" + } + } + } + } + }, + "description": "간편 로그인\n\n가입되어있지 않을 경우엔 가입됩니다.", + "tags": ["Auth"], + "security": [], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "provider", + "required": true, + "schema": { + "$ref": "#/components/schemas/OauthProvider" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SignInWithOauthRequestBody" + } + } + } + } + } + }, + "/{teamId}/articles/{articleId}/comments": { + "post": { + "operationId": "CreateArticleComment", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommentType" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "type": "object" + } + } + } + } + }, + "description": "게시글의 댓글 작성", + "tags": ["ArticleComment"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "articleId", + "required": true, + "schema": { + "format": "double", + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateCommentBody" + } + } + } + } + }, + "get": { + "operationId": "ListArticleComments", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CursorBasedPaginationResponse_CommentType_" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "type": "object" + } + } + } + } + }, + "description": "게시글의 댓글 목록 조회", + "tags": ["ArticleComment"], + "security": [], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "articleId", + "required": true, + "schema": { + "format": "double", + "type": "number" + } + }, + { + "in": "query", + "name": "limit", + "required": true, + "schema": { + "format": "double", + "type": "number" + } + }, + { + "in": "query", + "name": "cursor", + "required": false, + "schema": { + "format": "double", + "type": "number" + } + } + ] + } + }, + "/{teamId}/comments/{commentId}": { + "patch": { + "operationId": "UpdateComment", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommentType" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "type": "object" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "type": "object" + } + } + } + } + }, + "description": "댓글 수정", + "tags": ["ArticleComment"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "commentId", + "required": true, + "schema": { + "format": "double", + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateCommentBody" + } + } + } + } + }, + "delete": { + "operationId": "DeleteComment", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "properties": { + "id": { + "type": "number", + "format": "double" + } + }, + "required": ["id"], + "type": "object" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "type": "object" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "type": "object" + } + } + } + } + }, + "description": "댓글 삭제", + "tags": ["ArticleComment"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "commentId", + "required": true, + "schema": { + "format": "double", + "type": "number" + } + } + ] + } + }, + "/{teamId}/articles": { + "post": { + "operationId": "CreateArticle", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArticleListType" + } + } + } + } + }, + "tags": ["Article"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateArticleBody" + } + } + } + } + }, + "get": { + "operationId": "ListArticles", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OffsetBasedPaginationResponse_ArticleListType_" + } + } + } + } + }, + "description": "게시글 목록 조회", + "tags": ["Article"], + "security": [], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "페이지 번호", + "in": "query", + "name": "page", + "required": false, + "schema": { + "default": 1, + "format": "double", + "type": "number" + } + }, + { + "description": "페이지 당 게시글 수", + "in": "query", + "name": "pageSize", + "required": false, + "schema": { + "default": 10, + "format": "double", + "type": "number" + } + }, + { + "description": "정렬 기준", + "in": "query", + "name": "orderBy", + "required": false, + "schema": { + "$ref": "#/components/schemas/ListArticleOrder" + } + }, + { + "description": "검색 키워드", + "in": "query", + "name": "keyword", + "required": false, + "schema": { + "type": "string" + } + } + ] + } + }, + "/{teamId}/articles/{articleId}": { + "get": { + "operationId": "RetrieveArticle", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArticleDetailType" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "type": "object" + } + } + } + } + }, + "description": "게시글 상세 조회", + "tags": ["Article"], + "security": [ + { + "jwt": ["public"] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "articleId", + "required": true, + "schema": { + "format": "double", + "type": "number" + } + } + ] + }, + "patch": { + "operationId": "UpdateArticle", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArticleDetailType" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "type": "object" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "type": "object" + } + } + } + } + }, + "description": "게시글 수정", + "tags": ["Article"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "articleId", + "required": true, + "schema": { + "format": "double", + "type": "number" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateArticleBody" + } + } + } + } + }, + "delete": { + "operationId": "DeleteArticle", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "properties": { + "id": { + "type": "number", + "format": "double" + } + }, + "required": ["id"], + "type": "object" + } + } + } + }, + "403": { + "description": "", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "type": "object" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "type": "object" + } + } + } + } + }, + "description": "게시글 삭제", + "tags": ["Article"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "articleId", + "required": true, + "schema": { + "format": "double", + "type": "number" + } + } + ] + } + }, + "/{teamId}/articles/{articleId}/like": { + "post": { + "operationId": "LikeArticle", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArticleDetailType" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "type": "object" + } + } + } + } + }, + "description": "게시글 좋아요", + "tags": ["Article"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "articleId", + "required": true, + "schema": { + "format": "double", + "type": "number" + } + } + ] + }, + "delete": { + "operationId": "UnlikeArticle", + "responses": { + "200": { + "description": "Ok", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArticleDetailType" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "type": "object" + } + } + } + } + }, + "description": "게시글 좋아요 취소", + "tags": ["Article"], + "security": [ + { + "jwt": [] + } + ], + "parameters": [ + { + "in": "path", + "name": "teamId", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "articleId", + "required": true, + "schema": { + "format": "double", + "type": "number" + } + } + ] + } + } + }, + "servers": [ + { + "url": "https://fe-project-cowokers.vercel.app" + } + ] +} diff --git a/next.config.ts b/next.config.ts index 5e891cf..81f3423 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,48 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { - /* config options here */ + async headers() { + return [ + { + source: '/(.*)', + headers: [ + // HSTS: HTTPS 강제 (배포 환경에서 유효) + { + key: 'Strict-Transport-Security', + value: 'max-age=63072000; includeSubDomains; preload', + }, + // CSP: 허용할 리소스 출처 제한 + { + key: 'Content-Security-Policy', + value: [ + "default-src 'self'", + "script-src 'self' 'unsafe-inline' 'unsafe-eval'", + "style-src 'self' 'unsafe-inline'", + "img-src 'self' blob: data: https:", + "font-src 'self'", + "connect-src 'self' https://fe-project-cowokers.vercel.app", + "frame-ancestors 'none'", + ].join('; '), + }, + // 클릭재킹 방지 + { + key: 'X-Frame-Options', + value: 'DENY', + }, + // MIME 스니핑 방지 + { + key: 'X-Content-Type-Options', + value: 'nosniff', + }, + // 레퍼러 정보 제한 + { + key: 'Referrer-Policy', + value: 'strict-origin-when-cross-origin', + }, + ], + }, + ]; + }, }; export default nextConfig; diff --git a/package.json b/package.json index 8dbecdc..bfd04fc 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "react-calendar": "^6.0.0", "react-dom": "19.2.3", "react-hook-form": "^7.71.1", + "server-only": "^0.0.1", "zod": "^4.3.5" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 04f411b..9ed113d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: react-hook-form: specifier: ^7.71.1 version: 7.71.1(react@19.2.3) + server-only: + specifier: ^0.0.1 + version: 0.0.1 zod: specifier: ^4.3.5 version: 4.3.5 @@ -547,89 +550,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -729,24 +748,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@16.1.3': resolution: {integrity: sha512-UbFx69E2UP7MhzogJRMFvV9KdEn4sLGPicClwgqnLht2TEi204B71HuVfps3ymGAh0c44QRAF+ZmvZZhLLmhNg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@16.1.3': resolution: {integrity: sha512-SzGTfTjR5e9T+sZh5zXqG/oeRQufExxBF6MssXS7HPeZFE98JDhCRZXpSyCfWrWrYrzmnw/RVhlP2AxQm+wkRQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@16.1.3': resolution: {integrity: sha512-HlrDpj0v+JBIvQex1mXHq93Mht5qQmfyci+ZNwGClnAQldSfxI6h0Vupte1dSR4ueNv4q7qp5kTnmLOBIQnGow==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@16.1.3': resolution: {integrity: sha512-3gFCp83/LSduZMSIa+lBREP7+5e7FxpdBoc9QrCdmp+dapmTK9I+SLpY60Z39GDmTXSZA4huGg9WwmYbr6+WRw==} @@ -826,66 +849,79 @@ packages: resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.57.1': resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.57.1': resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.57.1': resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.57.1': resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.57.1': resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.57.1': resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.57.1': resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.57.1': resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.57.1': resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.57.1': resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.57.1': resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.57.1': resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.57.1': resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==} @@ -1202,41 +1238,49 @@ packages: resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==} cpu: [arm64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-arm64-musl@1.11.1': resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==} cpu: [arm64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-ppc64-gnu@1.11.1': resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-gnu@1.11.1': resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-riscv64-musl@1.11.1': resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==} cpu: [riscv64] os: [linux] + libc: [musl] '@unrs/resolver-binding-linux-s390x-gnu@1.11.1': resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==} cpu: [s390x] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-gnu@1.11.1': resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==} cpu: [x64] os: [linux] + libc: [glibc] '@unrs/resolver-binding-linux-x64-musl@1.11.1': resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==} cpu: [x64] os: [linux] + libc: [musl] '@unrs/resolver-binding-wasm32-wasi@1.11.1': resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==} @@ -2906,6 +2950,9 @@ packages: engines: {node: '>=10'} hasBin: true + server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} engines: {node: '>= 0.4'} @@ -6330,6 +6377,8 @@ snapshots: semver@7.7.3: {} + server-only@0.0.1: {} + set-function-length@1.2.2: dependencies: define-data-property: 1.1.4 diff --git a/src/app/api/auth/_lib/cookies.ts b/src/app/api/auth/_lib/cookies.ts new file mode 100644 index 0000000..25c9ec3 --- /dev/null +++ b/src/app/api/auth/_lib/cookies.ts @@ -0,0 +1,32 @@ +import { cookies } from 'next/headers'; + +// 액세스 토큰: 짧은 만료 (1시간) +const ACCESS_TOKEN_MAX_AGE = 60 * 60; +// 리프레시 토큰: 긴 만료 (7일) +const REFRESH_TOKEN_MAX_AGE = 60 * 60 * 24 * 7; + +export async function setAuthCookies(accessToken: string, refreshToken: string) { + const cookieStore = await cookies(); + + cookieStore.set('accessToken', accessToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: ACCESS_TOKEN_MAX_AGE, + }); + + cookieStore.set('refreshToken', refreshToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: REFRESH_TOKEN_MAX_AGE, + }); +} + +export async function clearAuthCookies() { + const cookieStore = await cookies(); + cookieStore.delete('accessToken'); + cookieStore.delete('refreshToken'); +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..12aeee1 --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { fetchApiServer } from '@/shared/apis/fetchApi.server'; +import { setAuthCookies } from '../_lib/cookies'; + +interface SignInResponseBody { + accessToken: string; + refreshToken: string; + user: { + id: number; + email: string; + nickname: string; + image: string | null; + teamId: string; + createdAt: string; + updatedAt: string; + }; +} + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + + const response = await fetchApiServer('/auth/signIn', { + method: 'POST', + body: JSON.stringify(body), + }); + + if (!response.ok) { + const error = await response.json(); + return NextResponse.json(error, { status: response.status }); + } + + const data: SignInResponseBody = await response.json(); + + await setAuthCookies(data.accessToken, data.refreshToken); + + // 토큰은 쿠키로 셋팅했으므로 클라이언트에 내려주지 않음 + return NextResponse.json({ user: data.user }); + } catch { + return NextResponse.json({ message: '로그인 중 오류가 발생했습니다.' }, { status: 500 }); + } +} diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..90351fe --- /dev/null +++ b/src/app/api/auth/logout/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from 'next/server'; +import { clearAuthCookies } from '../_lib/cookies'; + +export async function POST() { + try { + await clearAuthCookies(); + return NextResponse.json({ message: '로그아웃 되었습니다.' }); + } catch { + return NextResponse.json({ message: '로그아웃 중 오류가 발생했습니다.' }, { status: 500 }); + } +} diff --git a/src/app/api/auth/refresh/route.ts b/src/app/api/auth/refresh/route.ts new file mode 100644 index 0000000..d4c9b76 --- /dev/null +++ b/src/app/api/auth/refresh/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { fetchApiServer } from '@/shared/apis/fetchApi.server'; + +const ACCESS_TOKEN_MAX_AGE = 60 * 60; + +export async function POST() { + try { + const cookieStore = await cookies(); + const refreshToken = cookieStore.get('refreshToken')?.value; + + if (!refreshToken) { + return NextResponse.json({ message: '리프레시 토큰이 없습니다.' }, { status: 401 }); + } + + const response = await fetchApiServer('/auth/refresh-token', { + method: 'POST', + body: JSON.stringify({ refreshToken }), + }); + + if (!response.ok) { + const error = await response.json(); + return NextResponse.json(error, { status: response.status }); + } + + const data: { accessToken: string } = await response.json(); + + // 새 액세스 토큰을 쿠키로 업데이트 + cookieStore.set('accessToken', data.accessToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: ACCESS_TOKEN_MAX_AGE, + }); + + return NextResponse.json({ message: '토큰이 갱신되었습니다.' }); + } catch { + return NextResponse.json({ message: '토큰 갱신 중 오류가 발생했습니다.' }, { status: 500 }); + } +} diff --git a/src/app/api/auth/reset-password/route.ts b/src/app/api/auth/reset-password/route.ts new file mode 100644 index 0000000..3a05272 --- /dev/null +++ b/src/app/api/auth/reset-password/route.ts @@ -0,0 +1,26 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { fetchApiServer } from '@/shared/apis/fetchApi.server'; + +export async function PATCH(req: NextRequest) { + try { + const body = await req.json(); + + const response = await fetchApiServer('/user/reset-password', { + method: 'PATCH', + body: JSON.stringify(body), + }); + + if (!response.ok) { + const error = await response.json(); + return NextResponse.json(error, { status: response.status }); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch { + return NextResponse.json( + { message: '비밀번호 재설정 중 오류가 발생했습니다.' }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/auth/send-reset-password-email/route.ts b/src/app/api/auth/send-reset-password-email/route.ts new file mode 100644 index 0000000..ac6976a --- /dev/null +++ b/src/app/api/auth/send-reset-password-email/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { fetchApiServer } from '@/shared/apis/fetchApi.server'; + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + + // redirectUrl을 서버에서 자동 계산 + // 클라이언트가 환경마다 직접 넘길 필요 없이 서버에서 일관되게 처리 + const redirectUrl = new URL(req.url).origin; + + const response = await fetchApiServer('/user/send-reset-password-email', { + method: 'POST', + body: JSON.stringify({ + email: body.email, + redirectUrl, + }), + }); + + if (!response.ok) { + const error = await response.json(); + return NextResponse.json(error, { status: response.status }); + } + + const data = await response.json(); + return NextResponse.json(data); + } catch { + return NextResponse.json( + { message: '비밀번호 재설정 이메일 발송 중 오류가 발생했습니다.' }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/auth/signup/route.ts b/src/app/api/auth/signup/route.ts new file mode 100644 index 0000000..8467081 --- /dev/null +++ b/src/app/api/auth/signup/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { fetchApiServer } from '@/shared/apis/fetchApi.server'; +import { setAuthCookies } from '../_lib/cookies'; + +interface SignUpResponseBody { + accessToken: string; + refreshToken: string; + user: { + id: number; + email: string; + nickname: string; + image: string | null; + teamId: string; + createdAt: string; + updatedAt: string; + }; +} + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + + const response = await fetchApiServer('/auth/signUp', { + method: 'POST', + body: JSON.stringify(body), + }); + + if (!response.ok) { + const error = await response.json(); + return NextResponse.json(error, { status: response.status }); + } + + const data: SignUpResponseBody = await response.json(); + + await setAuthCookies(data.accessToken, data.refreshToken); + + // 토큰은 쿠키로 셋팅했으므로 클라이언트에 내려주지 않음 + return NextResponse.json({ user: data.user }); + } catch { + return NextResponse.json({ message: '회원가입 중 오류가 발생했습니다.' }, { status: 500 }); + } +} diff --git a/src/app/api/proxy/[...path]/route.ts b/src/app/api/proxy/[...path]/route.ts new file mode 100644 index 0000000..d2b0137 --- /dev/null +++ b/src/app/api/proxy/[...path]/route.ts @@ -0,0 +1,163 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { cookies } from 'next/headers'; +import { fetchApiServer } from '@/shared/apis/fetchApi.server'; + +// path prefix × method 허용 정책 +// images/*는 multipart 복잡도로 인해 별도 전용 라우트에서 처리 +// auth/*는 /api/auth/* 전용 라우트가 있으므로 프록시에서 차단 +const METHOD_POLICY: Record> = { + user: new Set(['GET', 'PATCH', 'DELETE']), + groups: new Set(['GET', 'POST', 'PATCH', 'DELETE']), + tasks: new Set(['GET', 'POST', 'PATCH', 'DELETE']), + articles: new Set(['GET', 'POST', 'PATCH', 'DELETE']), + comments: new Set(['GET', 'POST', 'PATCH', 'DELETE']), + oauthApps: new Set(['POST']), +}; + +const ALLOWED_PATH_PREFIXES = Object.keys(METHOD_POLICY); + +// 액세스 토큰 만료 여부 확인 (JWT payload의 exp 필드 기준) +function isTokenExpired(token: string): boolean { + try { + const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()); + return payload.exp * 1000 < Date.now(); + } catch { + return true; + } +} + +// 리프레시 토큰으로 액세스 토큰 재발급 +async function refreshAccessToken(refreshToken: string): Promise { + try { + const response = await fetchApiServer('/auth/refresh-token', { + method: 'POST', + body: JSON.stringify({ refreshToken }), + }); + + if (!response.ok) return null; + + const data: { accessToken: string } = await response.json(); + return data.accessToken; + } catch { + return null; + } +} + +async function proxyRequest( + req: NextRequest, + backendPath: string, + accessToken: string, +): Promise { + const searchParams = req.nextUrl.searchParams.toString(); + const pathWithQuery = searchParams ? `${backendPath}?${searchParams}` : backendPath; + + const method = req.method; + const isBodyless = method === 'GET' || method === 'HEAD'; + const contentType = req.headers.get('content-type') ?? ''; + + const body = isBodyless ? undefined : await req.text(); + + return fetchApiServer(pathWithQuery, { + method, + headers: { + Authorization: `Bearer ${accessToken}`, + ...(isBodyless ? {} : { 'Content-Type': contentType || 'application/json' }), + }, + body, + }); +} + +async function handleProxy(req: NextRequest, params: { path: string[] }) { + const method = req.method; + const pathSegments = params.path; + const firstSegment = pathSegments[0]; + + // 허용된 path prefix인지 확인 + if (!ALLOWED_PATH_PREFIXES.includes(firstSegment)) { + return NextResponse.json({ message: '허용되지 않는 경로입니다.' }, { status: 403 }); + } + + // 해당 prefix에서 허용된 메서드인지 확인 + if (!METHOD_POLICY[firstSegment].has(method)) { + return NextResponse.json( + { message: `${firstSegment} 경로에서 허용되지 않는 메서드입니다.` }, + { status: 405 }, + ); + } + + const backendPath = pathSegments.join('/'); + const cookieStore = await cookies(); + let accessToken = cookieStore.get('accessToken')?.value; + const refreshToken = cookieStore.get('refreshToken')?.value; + + // 토큰이 전혀 없으면 401 + if (!accessToken && !refreshToken) { + return NextResponse.json({ message: '인증이 필요합니다.' }, { status: 401 }); + } + + // 액세스 토큰이 만료됐거나 없으면 리프레시 시도 + if (!accessToken || isTokenExpired(accessToken)) { + if (!refreshToken) { + return NextResponse.json({ message: '다시 로그인해주세요.' }, { status: 401 }); + } + + const newAccessToken = await refreshAccessToken(refreshToken); + + if (!newAccessToken) { + // refresh 실패 시 쿠키 삭제 후 401 — 만료된 쿠키가 남아 무한 refresh 시도하는 것을 방지 + cookieStore.delete('accessToken'); + cookieStore.delete('refreshToken'); + return NextResponse.json( + { message: '세션이 만료되었습니다. 다시 로그인해주세요.' }, + { status: 401 }, + ); + } + + cookieStore.set('accessToken', newAccessToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 60 * 60, + }); + + accessToken = newAccessToken; + } + + try { + const response = await proxyRequest(req, backendPath, accessToken); + const responseData = await response.text(); + + return new NextResponse(responseData, { + status: response.status, + headers: { + 'Content-Type': response.headers.get('content-type') ?? 'application/json', + }, + }); + } catch { + return NextResponse.json({ message: '서버 오류가 발생했습니다.' }, { status: 500 }); + } +} + +export async function GET(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) { + return handleProxy(req, await params); +} + +export async function POST(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) { + return handleProxy(req, await params); +} + +export async function PATCH(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) { + return handleProxy(req, await params); +} + +export async function PUT(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) { + return handleProxy(req, await params); +} + +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ path: string[] }> }, +) { + return handleProxy(req, await params); +} diff --git a/src/shared/apis/config.ts b/src/shared/apis/config.ts index b95b025..9251ce6 100644 --- a/src/shared/apis/config.ts +++ b/src/shared/apis/config.ts @@ -1,12 +1,26 @@ -const apiBaseUrl = process.env.NEXT_PUBLIC_API_BASE_URL; -const apiTeamId = process.env.NEXT_PUBLIC_API_TEAM_ID; +// TODO: 팀원 코드 BFF 마이그레이션 완료 후 주석 해제 +// import 'server-only'; -if (!apiBaseUrl) { - throw new Error('NEXT_PUBLIC_API_BASE_URL is not defined.'); +// 빌드 타임이 아닌 런타임에 환경변수를 검증한다. +// 모듈 최상위에서 즉시 throw하면 Next.js 빌드의 page data collecting 단계에서 +// 환경변수가 주입되기 전에 에러가 발생하기 때문이다. +function getEnv(key: string): string { + const value = process.env[key]; + if (!value) { + throw new Error(`${key} is not defined.`); + } + return value; } -if (!apiTeamId) { - throw new Error('NEXT_PUBLIC_API_TEAM_ID is not defined.'); + +export function getBaseUrl(): string { + return getEnv('API_BASE_URL'); +} + +export function getTeamId(): string { + return getEnv('API_TEAM_ID'); } -export const BASE_URL = apiBaseUrl; -export const TEAM_ID = apiTeamId; +// 하위 호환성을 위한 getter (기존 코드에서 BASE_URL, TEAM_ID를 직접 참조하는 경우) +// TODO: 팀원 BFF 마이그레이션 완료 후 제거 +export const BASE_URL = process.env.API_BASE_URL ?? ''; +export const TEAM_ID = process.env.API_TEAM_ID ?? ''; diff --git a/src/shared/apis/fetchApi.ts b/src/shared/apis/fetchApi.server.ts similarity index 56% rename from src/shared/apis/fetchApi.ts rename to src/shared/apis/fetchApi.server.ts index 41496eb..285f56f 100644 --- a/src/shared/apis/fetchApi.ts +++ b/src/shared/apis/fetchApi.server.ts @@ -1,9 +1,14 @@ -import { BASE_URL, TEAM_ID } from './config'; +// TODO: 팀원 코드 BFF 마이그레이션 완료 후 주석 해제 +// import 'server-only'; + +import { getBaseUrl, getTeamId } from './config'; const BODYLESS_METHODS = new Set(['GET', 'HEAD']); -const NORMALIZED_BASE_URL = normalizeBaseUrl(BASE_URL); -const DEV_ACCESS_TOKEN = process.env.NEXT_PUBLIC_DEV_ACCESS_TOKEN; -const IS_DEVELOPMENT = process.env.NODE_ENV === 'development'; + +// 모듈 최상위에서 환경변수를 읽지 않는다. +// Next.js 빌드의 page data collecting 단계에서 환경변수 주입 전에 실행되면 +// "API_BASE_URL is not defined" 에러가 발생하기 때문이다. +// 실제 fetch 호출 시점(런타임)에 환경변수를 읽도록 buildApiUrl을 함수 내부로 이동한다. function normalizeBaseUrl(baseUrl: string) { return baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`; @@ -14,8 +19,10 @@ function normalizePath(path: string) { } function buildApiUrl(path: string) { - const relativePath = `${TEAM_ID}/${normalizePath(path)}`; - return new URL(relativePath, NORMALIZED_BASE_URL).toString(); + const baseUrl = normalizeBaseUrl(getBaseUrl()); + const teamId = getTeamId(); + const relativePath = `${teamId}/${normalizePath(path)}`; + return new URL(relativePath, baseUrl).toString(); } function getMethod(options: RequestInit) { @@ -39,13 +46,7 @@ function shouldSetJsonContentType(headers: Headers, body: RequestInit['body']) { return typeof body === 'string'; } -function shouldAttachDevAuthHeader(headers: Headers) { - if (!DEV_ACCESS_TOKEN) return false; - if (headers.has('Authorization')) return false; - return IS_DEVELOPMENT; -} - -export function fetchApi(path: string, options: RequestInit = {}) { +export function fetchApiServer(path: string, options: RequestInit = {}) { const method = getMethod(options); assertBodyAllowed(method, options.body); @@ -53,9 +54,6 @@ export function fetchApi(path: string, options: RequestInit = {}) { if (shouldSetJsonContentType(headers, options.body)) { headers.set('Content-Type', 'application/json'); } - if (shouldAttachDevAuthHeader(headers)) { - headers.set('Authorization', `Bearer ${DEV_ACCESS_TOKEN}`); - } return fetch(buildApiUrl(path), { ...options, @@ -63,3 +61,7 @@ export function fetchApi(path: string, options: RequestInit = {}) { headers, }); } + +// TODO: 팀원 BFF 마이그레이션 완료 후 제거 +// groups/http.ts, user/userApi.ts가 fetchApi 이름으로 import 중 — 호환용 alias +export const fetchApi = fetchApiServer; diff --git a/src/shared/apis/groups/http.ts b/src/shared/apis/groups/http.ts index 9c87779..63fdade 100644 --- a/src/shared/apis/groups/http.ts +++ b/src/shared/apis/groups/http.ts @@ -1,4 +1,4 @@ -import { fetchApi } from '../fetchApi'; +import { fetchApi } from '../fetchApi.server'; interface RequestErrorContext { message: string; diff --git a/src/shared/apis/user/userApi.ts b/src/shared/apis/user/userApi.ts index bf43172..f60205e 100644 --- a/src/shared/apis/user/userApi.ts +++ b/src/shared/apis/user/userApi.ts @@ -1,4 +1,4 @@ -import { fetchApi } from '../fetchApi'; +import { fetchApi } from '../fetchApi.server'; import { BASE_URL, TEAM_ID } from '../config'; import type { UserResponse,