[codex] Enable draft attachment uploads in Admin - #255
Conversation
There was a problem hiding this comment.
Pull request overview
Admin에서 신규 게시글 작성 시에도 첨부파일을 먼저 업로드할 수 있도록 draftSessionId 기반 임시 첨부 흐름을 도입하고, 게시글 최초 저장 이후 임시 첨부파일을 실제 postId에 일괄 연결(link) 하는 서버 엔드포인트를 추가한 PR입니다. 또한 저장 후 경고(첨부/달력) 발생 시에도 동일 저장 라우트 유지를 위해 메시지 전달 방식을 보완합니다.
Changes:
- (Admin) 신규 글 작성 중에도 첨부 업로드가 가능하도록 draftSessionId를 생성/전달하고, 저장 후 draft 첨부를 postId에 연결 호출 추가
- (Server) draft 첨부 → postId 연결용 Admin API(
POST /api/admin/files/link-draft) 추가 및 파일 메타데이터에 draftSessionId 저장 지원 - (Server) draftSessionId 정규화/쿼리 빌더 유닛 테스트 추가
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| Elevate.Server/tests/admin-attachments.test.js | draftSessionId 정규화/쿼리 및 createFileMetadata 입력 검증 테스트 추가 |
| Elevate.Server/src/functions/index.js | 새 Azure Function(adminLinkDraftFiles) 등록 및 라우팅 순서 보장 |
| Elevate.Server/src/functions/adminLinkDraftFiles.js | POST api/admin/files/link-draft 함수 정의 추가 |
| Elevate.Server/src/controllers/adminController.js | draftSessionId 저장/검증 및 draft 첨부를 postId로 연결하는 컨트롤러 로직 추가 |
| Elevate.Admin/src/services/assetsApi.js | draft 첨부 연결 API 호출 함수(linkDraftFilesToPost) 및 registerFile payload 문서 갱신 |
| Elevate.Admin/src/pages/PostEditor.jsx | draftSessionId 생성/보관, 신규 저장 후 draft 첨부 연결 호출, 저장 메시지 전달 방식 개선 |
| Elevate.Admin/src/components/editor/PostMetaSidebar.jsx | AttachUploader에 draftSessionId 전달 |
| Elevate.Admin/src/components/editor/AttachUploader.jsx | postId 없이도 업로드 가능하도록 draftSessionId 기반 업로드 허용 |
Comments suppressed due to low confidence (1)
Elevate.Admin/src/components/editor/AttachUploader.jsx:48
- 신규 글 작성 중(draftSessionId만 있는 상태)에도 첨부파일 업로드는 가능하지만, 현재 파일 목록 로딩(useEffect)이 postId가 있을 때만 동작해서 페이지 새로고침/재진입 시 업로드된 draft 첨부파일을 다시 조회·표시·삭제할 수 없습니다. draftSessionId를 보존(sessionStorage)하는 설계라면, 파일 목록 조회 API가 postId 대신 draftSessionId로도 조회 가능하도록 확장하고, 여기 useEffect에서도 postId가 없을 때 draftSessionId 기반 조회를 수행하는 편이 일관됩니다.
const canUpload = Boolean(postId || draftSessionId)
useEffect(() => {
let cancelled = false
// eslint-disable-next-line react-hooks/set-state-in-effect
setFiles([])
setConfirmDeleteId(null)
setError(null)
if (!postId) {
setLoadingFiles(false)
return () => { cancelled = true }
}
| test('normalizeDraftSessionId accepts generated draft ids', () => { | ||
| assert.equal( | ||
| _test.normalizeDraftSessionId('draft-123e4567-e89b-12d3-a456-426614174000'), | ||
| 'draft-123e4567-e89b-12d3-a456-426614174000' | ||
| ); |
| if (newId) { | ||
| try { localStorage.removeItem(storageKey) } catch { /* storage blocked — non-fatal */ } | ||
| navigate(`/posts/${newId}`, { state: { message: successMessage } }) | ||
| } else { |
| useEffect(() => { | ||
| if (!flashMessage) return | ||
| setMessage(flashMessage) | ||
| navigate(location.pathname, { replace: true, state: null }) | ||
| }, [flashMessage, location.pathname, navigate]) |
| const hasDraftSessionId = draftSessionId !== undefined && draftSessionId !== null; | ||
| const normalizedDraftSessionId = normalizeDraftSessionId(draftSessionId); | ||
| if (hasDraftSessionId && !normalizedDraftSessionId) { | ||
| return sendError(res, 400, 'BadRequest', 'Invalid draftSessionId', correlationId); | ||
| } |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
Elevate.Admin/src/components/editor/AttachUploader.jsx:48
- 신규 글 작성 중(draftSessionId만 있는 상태)에도 업로드는 가능해졌지만, 목록 로딩 useEffect가 postId만 기준으로 동작해서 새로고침/재진입 시 이미 올린 임시 첨부파일을 다시 불러오지 못합니다. draftSessionId로도 목록을 조회할 수 있도록 Admin API(getFiles 확장 또는 별도 엔드포인트)와 프론트의 getFiles/호출 로직을 함께 보완하는 편이 안전합니다.
const canUpload = Boolean(postId || draftSessionId)
useEffect(() => {
let cancelled = false
// eslint-disable-next-line react-hooks/set-state-in-effect
setFiles([])
setConfirmDeleteId(null)
setError(null)
if (!postId) {
setLoadingFiles(false)
return () => { cancelled = true }
}
| const correlationId = req.correlationId; | ||
| const { draftSessionId, postId } = req.body || {}; | ||
| const normalizedDraftSessionId = normalizeDraftSessionId(draftSessionId); | ||
|
|
||
| if (!normalizedDraftSessionId || !postId || typeof postId !== 'string') { | ||
| return sendError(res, 400, 'BadRequest', 'draftSessionId and postId are required', correlationId); | ||
| } |
| if (typeof value !== 'string') return null; | ||
| const trimmed = value.trim(); | ||
| if (!trimmed || trimmed.length > 80) return null; | ||
| if (!/^draft-[a-f0-9-]{8,}$/i.test(trimmed)) return null; |
| category: attachCategoryPartition, | ||
| partitionKey: attachCategoryPartition, | ||
| postId: normalizedPostId, | ||
| draftSessionId: normalizedPostId ? null : normalizedDraftSessionId, | ||
| blobUrl, |
| const container = getAssetsContainer(); | ||
| await cleanupExpiredDraftAttachments(container); | ||
| const fileId = createUuid(); | ||
| const now = new Date().toISOString(); | ||
| const isDraftAttachment = !normalizedPostId && normalizedDraftSessionId; |
| const result = await registerFile( | ||
| { | ||
| postId: postId || null, | ||
| draftSessionId: postId ? null : draftSessionId, | ||
| blobUrl: sas.blobUrl, |
| if (!normalizedPostId && !normalizedDraftSessionId) { | ||
| return sendError(res, 400, 'BadRequest', 'postId or draftSessionId is required', correlationId); | ||
| } |
| setMessage(`${flashMessage || '저장되었습니다.'} 첨부파일 연결을 다시 완료했습니다.`) | ||
| clearNavigationState() |
| const draftAttachmentStorageKey = `${storageKey}:attachments` | ||
| const [draftSessionId] = useState(() => { | ||
| if (!isNew) return '' | ||
| try { | ||
| const existing = sessionStorage.getItem(draftAttachmentStorageKey) | ||
| if (existing) return existing | ||
| const next = createDraftSessionId() | ||
| sessionStorage.setItem(draftAttachmentStorageKey, next) | ||
| return next | ||
| } catch { | ||
| return createDraftSessionId() | ||
| } | ||
| }) |
| function buildExpiredDraftAttachmentQuery(nowIso) { | ||
| return { | ||
| query: 'SELECT * FROM c WHERE c.documentType = "attach" AND IS_DEFINED(c.draftSessionId) AND c.draftSessionId != null AND c.expiresAt < @now', | ||
| parameters: [{ name: '@now', value: nowIso }] | ||
| }; |
| useEffect(() => { | ||
| setDraftSessionId(isNew ? getDraftSessionId(draftAttachmentStorageKey) : '') | ||
| }, [draftAttachmentStorageKey, isNew]) |
| await Promise.all(resources.map(async (file) => { | ||
| try { | ||
| await deleteBlobByUrl(file.blobUrl); | ||
| } catch (err) { | ||
| console.error(`[cleanupExpiredDraftAttachments] blob deletion failed for file ${file.id}`, err); | ||
| } | ||
| try { | ||
| await container.item(file.id, file.category || file.partitionKey || attachCategoryPartition).delete(); | ||
| } catch (err) { | ||
| console.error(`[cleanupExpiredDraftAttachments] cosmos deletion failed for file ${file.id}`, err); | ||
| } | ||
| })); |
| await Promise.all(resources.map(async (file) => { | ||
| const updated = { | ||
| ...file, | ||
| postId: normalizedPostId, | ||
| draftSessionId: null, | ||
| expiresAt: null, | ||
| ttl: null, | ||
| updatedAt: new Date().toISOString() | ||
| }; | ||
| await container.item(file.id, file.category || file.partitionKey || attachCategoryPartition).replace(updated); | ||
| })); |
| if (newId && draftSessionId) { | ||
| try { | ||
| await linkDraftFilesToPost({ draftSessionId, postId: newId }, { msalInstance }) | ||
| try { sessionStorage.removeItem(draftAttachmentStorageKey) } catch { /* storage blocked — non-fatal */ } | ||
| } catch (linkError) { |
| const [status, setStatus] = useState(null) // null | 'uploading' | 'done' | 'error' | ||
| const [files, setFiles] = useState([]) // [{ id, fileName, blobUrl, isDeleting }] | ||
| const [loadingFiles, setLoadingFiles] = useState(false) |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
Elevate.Admin/src/services/assetsApi.js:90
- getFiles는 params에 postId/draftSessionId가 없거나(예: {}), 혹은 둘 다 있는 경우에도 그대로
/files?요청을 만들어 서버 400으로 이어질 수 있습니다. 호출자 오사용을 빠르게 드러내기 위해 클라이언트에서 입력값을 검증하고, 둘 중 하나만 허용하도록 명시적으로 실패시키는 편이 유지보수에 안전합니다.
export function getFiles(params, options = {}) {
const query = new URLSearchParams()
if (typeof params === 'string') {
query.set('postId', params)
} else if (params?.postId) {
query.set('postId', params.postId)
} else if (params?.draftSessionId) {
query.set('draftSessionId', params.draftSessionId)
}
return apiFetch(`/files?${query.toString()}`, {
...options,
method: 'GET',
})
}
Summary
Root Cause
The Admin attachment picker was disabled whenever
postIdwas missing. New post creation has nopostIduntil the first save, so attachments could only be added after saving and reopening the saved post.Validation
npm --prefix Elevate.Server testnpm --prefix Elevate.Admin run lintnpm --prefix Elevate.Admin run buildgit diff --check develop...HEAD