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,