feat: 사용여부를 백엔드 실제 API로 연결 + 목록 응답 신규 필드 매핑
백엔드를 최신화(develop 924db37 → 4d98756)하고 확인하니 막고 있던 두 가지가 모두 풀렸다.
- `GET /api/v1/mngr/user/pagination`이 4개 필드에서 18개로 늘었다 — userTelno·userEmlAddr·
userType·schNm·grade·clsNo·birth·useYn이 실린다.
- `PUT /api/v1/mngr/user/{userId}/{useYn}` 사용여부 변경 API가 생겼다.
- (덤) `GET /api/v1/mngr/user/{userId}` 단건 조회도 생겼다. 지금은 쓰지 않는다 — 목록 행이
이미 갖고 있는 값으로 팝업을 채우므로 재조회가 필요 없다.
이에 맞춰 이식했다.
- Repository: toStudentMember가 전화·이메일·학교·학년·반·생년월일·사용여부를 실제 값으로
채운다. `useYn`은 'Y'/'N' 문자열이라 boolean으로 옮기고, 값이 없으면 판단하지 않고 null이다.
보호자·학생번호·가입일은 응답에 여전히 없어 `-`로 남는다(SQL·VO 어디에도 컬럼이 없다).
- Repository: updateStudentMemberUseYn 추가. 값과 대상이 모두 경로에 실리고 본문은 쓰지 않는다.
- Server Action: 미지원 반환을 걷어내고 실제 호출로 바꿨다. 성공하면 목록을 revalidate한다.
- 팝업: 저장 성공 시 닫는 동작을 되살렸다.
backend-fetch에 `canHaveEmptyBody`를 더했다 — 이 PUT은 인터페이스에 `ApiResponseVO` 반환으로
문서화돼 있지만 구현이 `void`이고 `ApiResponseVO.success(null)`을 만들어 놓고 버려서, 본문 없는
200이 나간다. 봉투를 파싱하기 전에 빈 본문을 갈라 성공으로 받는다. 백엔드가 봉투를 돌려주도록
고쳐도 그대로 동작한다.
감사 컬럼(LAST_MDFR_*)은 보내지 않는다 — 매퍼가 쓰지만 백엔드 CrudLogInterceptor가 UPDATE마다
자동으로 채운다(확인함).
Co-Authored-By: Claude Opus 5
@18e8e584173b4286d384e153d7916952bb7b4335
--- app/(protected)/(basic)/students/_actions.ts
+++ app/(protected)/(basic)/students/_actions.ts
... | ... | @@ -1,34 +1,49 @@ |
| 1 | 1 |
'use server'; |
| 2 | 2 |
|
| 3 |
+import { revalidatePath } from 'next/cache';
|
|
| 3 | 4 |
import { verifySession } from '@/lib/auth/dal';
|
| 5 |
+import { updateStudentMemberUseYn } from '@/lib/data/repositories/student-member-repository';
|
|
| 6 |
+import { STUDENT_MEMBERS_PATH } from '@/lib/domain/student-member-query';
|
|
| 4 | 7 |
|
| 5 | 8 |
export type UpdateStudentActiveStatusState = |
| 6 | 9 |
| { status: 'idle' }
|
| 7 | 10 |
| { status: 'error'; error: string }
|
| 8 | 11 |
| { status: 'success' };
|
| 9 | 12 |
|
| 10 |
-const UNSUPPORTED_ERROR = |
|
| 11 |
- '사용여부 변경은 아직 제공되지 않습니다. (백엔드 API 준비 중)'; |
|
| 13 |
+const INVALID_INPUT_ERROR = '요청 값이 올바르지 않습니다.'; |
|
| 12 | 14 |
|
| 13 | 15 |
/** |
| 14 |
- * 학생 회원 사용여부 변경 Server Action — **현재는 미지원 상태의 이식 지점**이다. |
|
| 16 |
+ * 학생 회원 사용여부 변경 Server Action. |
|
| 15 | 17 |
* |
| 16 |
- * 목록이 백엔드 API(`GET /api/v1/mngr/user/pagination`)로 전환되면서 이 화면의 데이터 원천은 |
|
| 17 |
- * 백엔드가 됐지만, 사용여부는 **조회 응답에 값도 없고 변경 API도 없다.** 이전의 mock 쓰기를 |
|
| 18 |
- * 그대로 두면 mock 배열에만 존재하는 id를 찾다가 "존재하지 않는 학생 회원" 오류가 나므로 |
|
| 19 |
- * (목록의 id는 이제 백엔드 `userId`다) 쓰기 경로를 명시적으로 막았다. 팝업의 사용여부 라디오와 |
|
| 20 |
- * 저장 버튼도 같은 이유로 비활성이다. |
|
| 18 |
+ * PUT /api/v1/mngr/user/{userId}/{useYn}
|
|
| 21 | 19 |
* |
| 22 |
- * 변경 API가 생기면 `formData: FormData` 인자를 되살리고 본문을 "입력 검증 → Repository 호출 |
|
| 23 |
- * → `revalidatePath`"로 되돌리면 된다(지금은 읽을 입력이 없어 인자를 받지 않는다 — 인자를 |
|
| 24 |
- * 줄여도 `useActionState`의 호출 규약에는 어긋나지 않는다). 인증 확인을 본문 맨 앞에 남겨 둔 |
|
| 25 |
- * 것도 그 형태를 유지하기 위함이다 — Server Action은 UI를 거치지 않고 직접 POST될 수 있어 |
|
| 26 |
- * 이 확인이 유일한 최종 방어선이다. |
|
| 20 |
+ * 백엔드에 값도 변경 API도 없어 한동안 막아 두었던 경로다. edupay-backend develop 4d98756에서 |
|
| 21 |
+ * 목록 응답에 `useYn`이 실리고 변경 API가 생기면서 되살렸다. |
|
| 22 |
+ * |
|
| 23 |
+ * 인증 확인을 본문 맨 앞에 둔다 — Server Action은 UI를 거치지 않고 직접 POST될 수 있어 이 |
|
| 24 |
+ * 확인이 유일한 최종 방어선이다. 실패 사유는 일반화된 문구만 화면으로 보낸다(백엔드 message |
|
| 25 |
+ * 원문에는 내부 정보가 실릴 수 있다). |
|
| 27 | 26 |
*/ |
| 28 | 27 |
export async function updateStudentActiveStatus( |
| 29 |
- _prevState: UpdateStudentActiveStatusState |
|
| 28 |
+ _prevState: UpdateStudentActiveStatusState, |
|
| 29 |
+ formData: FormData |
|
| 30 | 30 |
): Promise<UpdateStudentActiveStatusState> {
|
| 31 | 31 |
await verifySession(); |
| 32 | 32 |
|
| 33 |
- return { status: 'error', error: UNSUPPORTED_ERROR };
|
|
| 33 |
+ const id = formData.get('id');
|
|
| 34 |
+ if (typeof id !== 'string' || id.trim() === '') {
|
|
| 35 |
+ return { status: 'error', error: INVALID_INPUT_ERROR };
|
|
| 36 |
+ } |
|
| 37 |
+ |
|
| 38 |
+ // 체크박스는 켜졌을 때만 값이 실린다 — 없으면 꺼진 것이다. |
|
| 39 |
+ const isActive = formData.get('isActive') !== null;
|
|
| 40 |
+ |
|
| 41 |
+ const result = await updateStudentMemberUseYn(id, isActive); |
|
| 42 |
+ if (!result.ok) {
|
|
| 43 |
+ return { status: 'error', error: result.message };
|
|
| 44 |
+ } |
|
| 45 |
+ |
|
| 46 |
+ // 목록의 사용여부 열이 방금 바꾼 값을 반영해야 한다. |
|
| 47 |
+ revalidatePath(STUDENT_MEMBERS_PATH); |
|
| 48 |
+ return { status: 'success' };
|
|
| 34 | 49 |
} |
--- app/(protected)/(basic)/students/_components/student-detail-modal.tsx
+++ app/(protected)/(basic)/students/_components/student-detail-modal.tsx
... | ... | @@ -1,6 +1,6 @@ |
| 1 | 1 |
'use client'; |
| 2 | 2 |
|
| 3 |
-import { useActionState, useRef, useState } from 'react';
|
|
| 3 |
+import { useActionState, useEffect, useRef, useState } from 'react';
|
|
| 4 | 4 |
import { FoxButton } from '@fox/core/components/fox-button';
|
| 5 | 5 |
import {
|
| 6 | 6 |
FoxDescriptionList, |
... | ... | @@ -44,9 +44,8 @@ |
| 44 | 44 |
* 시안은 보호자(이름/연락처)와 학교(학교/학년·반·번호)를 각각 한 줄로 묶는다 — 종전처럼 |
| 45 | 45 |
* 항목을 따로 두지 않고 시안의 8줄 구성을 그대로 따랐다. |
| 46 | 46 |
* |
| 47 |
- * 사용여부는 이 팝업에서 유일하게 수정 가능한 항목이지만, 백엔드 목록 응답에 값이 없고 변경 |
|
| 48 |
- * API도 없어 토글·저장 버튼을 비활성으로 둔다(`_actions.ts`의 미지원 처리와 짝). 폼·Server |
|
| 49 |
- * Action 배선은 남겨 두었으므로 API가 생기면 `IS_ACTIVE_STATUS_EDITABLE`만 되돌리면 된다. |
|
| 47 |
+ * 사용여부는 이 팝업에서 유일하게 수정 가능한 항목이다. 토글을 넘기면 화면이 바로 반응하고, |
|
| 48 |
+ * 저장을 누르면 `PUT /api/v1/mngr/user/{userId}/{useYn}`로 나간다.
|
|
| 50 | 49 |
* |
| 51 | 50 |
* 저장 버튼은 `form={DETAIL_FORM_ID}`로 폼과 이어 둔다 — FoxModal이 버튼을 `foot` 슬롯에
|
| 52 | 51 |
* 그려 폼의 자손이 아니게 되는데, 네이티브 `form` 속성은 같은 문서 안에서 id만 맞으면 그 |
... | ... | @@ -59,9 +58,16 @@ |
| 59 | 58 |
); |
| 60 | 59 |
const formRef = useRef<HTMLFormElement>(null); |
| 61 | 60 |
// 토글은 화면에서 바로 반응해야 하므로 이 컴포넌트가 상태를 갖는다 — prop에서 계산하면 |
| 62 |
- // 스위치를 넘겨도 라벨이 그대로다. 저장이 되지 않으므로 이 값은 화면에만 머문다. |
|
| 61 |
+ // 스위치를 넘겨도 라벨이 그대로다. 저장하면 이 값이 폼에 실려 나간다. |
|
| 63 | 62 |
const [isActive, setIsActive] = useState(member.isActive ?? false); |
| 64 | 63 |
|
| 64 |
+ // 저장에 성공하면 닫는다. 목록은 Server Action의 revalidatePath가 다시 그린다. |
|
| 65 |
+ useEffect(() => {
|
|
| 66 |
+ if (state.status === 'success') {
|
|
| 67 |
+ onClose(); |
|
| 68 |
+ } |
|
| 69 |
+ }, [state, onClose]); |
|
| 70 |
+ |
|
| 65 | 71 |
const items: FoxDescriptionItem[] = [ |
| 66 | 72 |
{ key: 'name', term: '이름', description: member.name },
|
| 67 | 73 |
{ key: 'loginId', term: 'ID', description: member.loginId },
|
--- lib/data/repositories/student-member-repository.ts
+++ lib/data/repositories/student-member-repository.ts
... | ... | @@ -1,6 +1,10 @@ |
| 1 | 1 |
import 'server-only'; |
| 2 | 2 |
import { getSessionAccessToken } from '@/lib/auth/dal';
|
| 3 |
-import { BackendRequestError, backendFetch } from '@/lib/http/backend-fetch';
|
|
| 3 |
+import {
|
|
| 4 |
+ BackendRequestError, |
|
| 5 |
+ backendFetch, |
|
| 6 |
+ type BackendResult, |
|
| 7 |
+} from '@/lib/http/backend-fetch'; |
|
| 4 | 8 |
import type { StudentMember } from '@/lib/domain/student-member';
|
| 5 | 9 |
import type {
|
| 6 | 10 |
StudentMemberQuery, |
... | ... | @@ -13,7 +17,8 @@ |
| 13 | 17 |
* `lib/http/backend-fetch.ts`가, 토큰 보관·검증은 `lib/auth`가 소유하므로 여기에 들어오지 않는다. |
| 14 | 18 |
* |
| 15 | 19 |
* GET /api/v1/mngr/user/pagination (ROLE_ADMIN 전용) |
| 16 |
- * → data: { list: [{ rnum, userId, loginId, userNm }], page, size, totalCount, totalPages }
|
|
| 20 |
+ * → data: { list: [{ rnum, userId, userNm, userTelno, userEmlAddr, userType, loginId, schNm,
|
|
| 21 |
+ * grade, clsNo, birth, useYn, … }], page, size, totalCount, totalPages } |
|
| 17 | 22 |
* |
| 18 | 23 |
* 아래 내용은 백엔드 저장소(edupay-backend, develop b742bb4)의 실제 구현을 읽고 확인한 것이다 |
| 19 | 24 |
* — MngrUserApiController / MngrUserServiceImpl / PaginationUtil / MngrUserMapper.xml. |
... | ... | @@ -37,6 +42,7 @@ |
| 37 | 42 |
*/ |
| 38 | 43 |
|
| 39 | 44 |
const STUDENT_MEMBER_PAGINATION_PATH = '/api/v1/mngr/user/pagination'; |
| 45 |
+const STUDENT_MEMBER_BASE_PATH = '/api/v1/mngr/user'; |
|
| 40 | 46 |
|
| 41 | 47 |
/** |
| 42 | 48 |
* 화면의 "검색 대상" → 백엔드 `searchCondition` 값 매핑. 값은 MngrUserMapper.xml의 |
... | ... | @@ -115,6 +121,25 @@ |
| 115 | 121 |
return value; |
| 116 | 122 |
} |
| 117 | 123 |
|
| 124 |
+/** 있으면 문자열로, 없거나 비어 있으면 null. 백엔드가 빈 문자열로 "없음"을 표현하기도 한다. */ |
|
| 125 |
+function readOptionalString(raw: Record<string, unknown>, key: string): string | null {
|
|
| 126 |
+ const value = raw[key]; |
|
| 127 |
+ if (typeof value === 'string') {
|
|
| 128 |
+ return value.trim() === '' ? null : value; |
|
| 129 |
+ } |
|
| 130 |
+ return typeof value === 'number' ? String(value) : null; |
|
| 131 |
+} |
|
| 132 |
+ |
|
| 133 |
+/** 학년·반처럼 숫자로 쓰는 값. 숫자로 읽히지 않으면 null이다(백엔드가 문자열로 준다). */ |
|
| 134 |
+function readOptionalNumber(raw: Record<string, unknown>, key: string): number | null {
|
|
| 135 |
+ const text = readOptionalString(raw, key); |
|
| 136 |
+ if (text === null) {
|
|
| 137 |
+ return null; |
|
| 138 |
+ } |
|
| 139 |
+ const parsed = Number(text); |
|
| 140 |
+ return Number.isFinite(parsed) ? parsed : null; |
|
| 141 |
+} |
|
| 142 |
+ |
|
| 118 | 143 |
/** |
| 119 | 144 |
* 백엔드 응답 1건 → 도메인 타입. 백엔드가 주지 않는 항목은 `null`로 둔다(설계서 §8.1 5층 — |
| 120 | 145 |
* 원본 응답을 그대로 흘리지 않고 화면에 필요한 필드만 골라 담는다). |
... | ... | @@ -125,6 +150,15 @@ |
| 125 | 150 |
* 식별자·이름 세 필드는 없으면 예외로 끊는다(fail-fast) — 목록의 존재 이유인 값이라 |
| 126 | 151 |
* 빈 화면을 조용히 보여주는 것보다 계약 위반을 즉시 드러내는 편이 낫다. |
| 127 | 152 |
*/ |
| 153 |
+/** 백엔드의 `useYn`은 'Y'/'N' 문자열이다. 값이 없으면 판단하지 않고 null로 둔다. */ |
|
| 154 |
+function readUseYn(raw: Record<string, unknown>): boolean | null {
|
|
| 155 |
+ const value = readOptionalString(raw, 'useYn'); |
|
| 156 |
+ if (value === null) {
|
|
| 157 |
+ return null; |
|
| 158 |
+ } |
|
| 159 |
+ return value.toUpperCase() === 'Y'; |
|
| 160 |
+} |
|
| 161 |
+ |
|
| 128 | 162 |
function toStudentMember(raw: unknown): StudentMember {
|
| 129 | 163 |
if (!isRecord(raw)) {
|
| 130 | 164 |
throw new Error('학생 회원 응답 항목의 형식이 올바르지 않습니다.');
|
... | ... | @@ -138,18 +172,20 @@ |
| 138 | 172 |
memberCode: userId, |
| 139 | 173 |
name: readRequiredString(raw, 'userNm'), |
| 140 | 174 |
loginId: readRequiredString(raw, 'loginId'), |
| 141 |
- phoneNumber: null, |
|
| 142 |
- email: null, |
|
| 175 |
+ phoneNumber: readOptionalString(raw, 'userTelno'), |
|
| 176 |
+ email: readOptionalString(raw, 'userEmlAddr'), |
|
| 143 | 177 |
role: STUDENT_ROLE_LABEL, |
| 144 |
- schoolName: null, |
|
| 145 |
- grade: null, |
|
| 146 |
- classNumber: null, |
|
| 178 |
+ schoolName: readOptionalString(raw, 'schNm'), |
|
| 179 |
+ grade: readOptionalNumber(raw, 'grade'), |
|
| 180 |
+ classNumber: readOptionalNumber(raw, 'clsNo'), |
|
| 181 |
+ // 학생 번호는 응답에 없다 — 목록 SQL에도 VO에도 해당 컬럼이 없다. |
|
| 147 | 182 |
studentNumber: null, |
| 183 |
+ // 보호자 정보와 가입일도 아직 응답에 없다. |
|
| 148 | 184 |
guardianName: null, |
| 149 | 185 |
guardianPhoneNumber: null, |
| 150 | 186 |
joinedAt: null, |
| 151 |
- birthDate: null, |
|
| 152 |
- isActive: null, |
|
| 187 |
+ birthDate: readOptionalString(raw, 'birth'), |
|
| 188 |
+ isActive: readUseYn(raw), |
|
| 153 | 189 |
}; |
| 154 | 190 |
} |
| 155 | 191 |
|
... | ... | @@ -277,3 +313,38 @@ |
| 277 | 313 |
): Promise<StudentMemberPage> {
|
| 278 | 314 |
return query.sort === 'name' ? fetchByName(query) : fetchByJoinedAt(query); |
| 279 | 315 |
} |
| 316 |
+ |
|
| 317 |
+/** |
|
| 318 |
+ * 사용여부 변경. |
|
| 319 |
+ * |
|
| 320 |
+ * PUT /api/v1/mngr/user/{userId}/{useYn} (ROLE_ADMIN 전용)
|
|
| 321 |
+ * |
|
| 322 |
+ * 값과 대상이 모두 **경로에** 실린다 — 본문도 쿼리도 쓰지 않는다. |
|
| 323 |
+ * |
|
| 324 |
+ * ⚠️ 이 엔드포인트는 **성공 시 본문이 비어 온다.** 인터페이스에는 `ApiResponseVO` 반환으로 |
|
| 325 |
+ * 문서화돼 있지만 구현이 `void`이고 `ApiResponseVO.success(null)`을 만들어 놓고 버린다 |
|
| 326 |
+ * (edupay-backend develop 4d98756의 `MngrUserApiController.updateUserUseYn`). 그래서 |
|
| 327 |
+ * `canHaveEmptyBody`로 봉투 없는 2xx를 성공으로 받는다 — 백엔드가 봉투를 돌려주도록 고쳐도 |
|
| 328 |
+ * 그대로 동작한다. |
|
| 329 |
+ * |
|
| 330 |
+ * 최종수정자 정보는 보내지 않는다. 매퍼가 `LAST_MDFR_*`를 쓰지만 그 값은 백엔드의 |
|
| 331 |
+ * `CrudLogInterceptor`가 UPDATE마다 자동으로 채운다. |
|
| 332 |
+ */ |
|
| 333 |
+export async function updateStudentMemberUseYn( |
|
| 334 |
+ userId: string, |
|
| 335 |
+ isActive: boolean |
|
| 336 |
+): Promise<BackendResult<null>> {
|
|
| 337 |
+ const accessToken = await getSessionAccessToken(); |
|
| 338 |
+ |
|
| 339 |
+ return backendFetch<null>( |
|
| 340 |
+ `${STUDENT_MEMBER_BASE_PATH}/${encodeURIComponent(userId)}/${
|
|
| 341 |
+ isActive ? 'Y' : 'N' |
|
| 342 |
+ }`, |
|
| 343 |
+ {
|
|
| 344 |
+ method: 'PUT', |
|
| 345 |
+ accessToken: accessToken ?? undefined, |
|
| 346 |
+ canHaveEmptyBody: true, |
|
| 347 |
+ canHaveNullData: true, |
|
| 348 |
+ } |
|
| 349 |
+ ); |
|
| 350 |
+} |
--- lib/http/backend-fetch.ts
+++ lib/http/backend-fetch.ts
... | ... | @@ -89,6 +89,20 @@ |
| 89 | 89 |
* null이 직렬화됨). |
| 90 | 90 |
*/ |
| 91 | 91 |
canHaveNullData?: boolean; |
| 92 |
+ /** |
|
| 93 |
+ * 이 호출은 성공 시 **본문이 비어 올 수 있다.** 봉투를 파싱하지 않고 2xx를 그대로 성공으로 |
|
| 94 |
+ * 본다(`data`는 null). |
|
| 95 |
+ * |
|
| 96 |
+ * 백엔드가 봉투를 안 주는 엔드포인트가 실제로 있다 — 사용여부 변경 |
|
| 97 |
+ * (`PUT /api/v1/mngr/user/{userId}/{useYn}`)은 인터페이스에 `ApiResponseVO` 반환으로
|
|
| 98 |
+ * 문서화돼 있지만 구현이 `void`이고 `ApiResponseVO.success(null)`을 만들어 놓고 버려서, |
|
| 99 |
+ * `@RestController` + `void` + `HttpServletResponse` 조합상 본문 없는 200이 나간다 |
|
| 100 |
+ * (edupay-backend develop 4d98756 확인). |
|
| 101 |
+ * |
|
| 102 |
+ * 백엔드가 봉투를 돌려주도록 고쳐도 이 플래그를 그대로 둘 수 있다 — 본문이 있으면 아래에서 |
|
| 103 |
+ * 정상적으로 파싱한다. |
|
| 104 |
+ */ |
|
| 105 |
+ canHaveEmptyBody?: boolean; |
|
| 92 | 106 |
/** 기본 타임아웃보다 오래 걸리는 호출(파일 업로드 등)이 값을 올려 잡는다. */ |
| 93 | 107 |
timeoutMs?: number; |
| 94 | 108 |
}; |
... | ... | @@ -268,6 +282,30 @@ |
| 268 | 282 |
return communicationError(`예상치 못한 HTTP 상태: ${response.status}`, undefined);
|
| 269 | 283 |
} |
| 270 | 284 |
|
| 285 |
+ // 본문 없는 2xx를 허용한 호출은 먼저 글자를 읽어 비었는지 본다 — 비어 있으면 `response.json()`이 |
|
| 286 |
+ // 파싱 오류를 던지므로 그 전에 갈라야 한다. |
|
| 287 |
+ if (init.canHaveEmptyBody) {
|
|
| 288 |
+ let raw: string; |
|
| 289 |
+ try {
|
|
| 290 |
+ raw = await response.text(); |
|
| 291 |
+ } catch (error) {
|
|
| 292 |
+ return communicationError('응답 읽기 실패', error);
|
|
| 293 |
+ } |
|
| 294 |
+ |
|
| 295 |
+ if (raw.trim() === '') {
|
|
| 296 |
+ return { ok: true, data: null as T };
|
|
| 297 |
+ } |
|
| 298 |
+ |
|
| 299 |
+ try {
|
|
| 300 |
+ const parsed = JSON.parse(raw) as BackendEnvelope<T>; |
|
| 301 |
+ return parsed.success |
|
| 302 |
+ ? { ok: true, data: parsed.data as T }
|
|
| 303 |
+ : { ok: false, code: parsed.code, message: parsed.message };
|
|
| 304 |
+ } catch (error) {
|
|
| 305 |
+ return communicationError('응답 파싱 실패', error);
|
|
| 306 |
+ } |
|
| 307 |
+ } |
|
| 308 |
+ |
|
| 271 | 309 |
let envelope: BackendEnvelope<T>; |
| 272 | 310 |
try {
|
| 273 | 311 |
envelope = (await response.json()) as BackendEnvelope<T>; |
Add a comment
Delete comment
Once you delete this comment, you won't be able to recover it. Are you sure you want to delete this comment?