Skip to content

[codex] Enable draft attachment uploads in Admin - #255

Merged
YoonKeumJae merged 18 commits into
oneot:developfrom
YoonKeumJae:codex/admin-draft-attachments-plan
Jun 8, 2026
Merged

[codex] Enable draft attachment uploads in Admin#255
YoonKeumJae merged 18 commits into
oneot:developfrom
YoonKeumJae:codex/admin-draft-attachments-plan

Conversation

@YoonKeumJae

Copy link
Copy Markdown
Collaborator

Summary

  • Enables attachment upload while creating a new Admin post by using a client-generated draft session id.
  • Adds an Admin API endpoint to link draft attachments to the saved post after first creation.
  • Preserves the saved post route after post-save attachment/calendar warnings to avoid duplicate post creation.

Root Cause

The Admin attachment picker was disabled whenever postId was missing. New post creation has no postId until the first save, so attachments could only be added after saving and reopening the saved post.

Validation

  • npm --prefix Elevate.Server test
  • npm --prefix Elevate.Admin run lint
  • npm --prefix Elevate.Admin run build
  • git diff --check develop...HEAD

@YoonKeumJae
YoonKeumJae marked this pull request as ready for review June 8, 2026 06:55
Copilot AI review requested due to automatic review settings June 8, 2026 06:55

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 }
    }

Comment on lines +74 to +78
test('normalizeDraftSessionId accepts generated draft ids', () => {
assert.equal(
_test.normalizeDraftSessionId('draft-123e4567-e89b-12d3-a456-426614174000'),
'draft-123e4567-e89b-12d3-a456-426614174000'
);

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.

Comment on lines +291 to +294
if (newId) {
try { localStorage.removeItem(storageKey) } catch { /* storage blocked — non-fatal */ }
navigate(`/posts/${newId}`, { state: { message: successMessage } })
} else {
Comment thread Elevate.Admin/src/pages/PostEditor.jsx Outdated
Comment on lines +116 to +120
useEffect(() => {
if (!flashMessage) return
setMessage(flashMessage)
navigate(location.pathname, { replace: true, state: null })
}, [flashMessage, location.pathname, navigate])
Comment on lines +817 to +821
const hasDraftSessionId = draftSessionId !== undefined && draftSessionId !== null;
const normalizedDraftSessionId = normalizeDraftSessionId(draftSessionId);
if (hasDraftSessionId && !normalizedDraftSessionId) {
return sendError(res, 400, 'BadRequest', 'Invalid draftSessionId', correlationId);
}

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 }
    }

Comment on lines +869 to +875
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);
}

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

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;
Comment on lines +839 to +843
category: attachCategoryPartition,
partitionKey: attachCategoryPartition,
postId: normalizedPostId,
draftSessionId: normalizedPostId ? null : normalizedDraftSessionId,
blobUrl,

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.

Comment on lines +867 to +871
const container = getAssetsContainer();
await cleanupExpiredDraftAttachments(container);
const fileId = createUuid();
const now = new Date().toISOString();
const isDraftAttachment = !normalizedPostId && normalizedDraftSessionId;
Comment on lines 85 to 89
const result = await registerFile(
{
postId: postId || null,
draftSessionId: postId ? null : draftSessionId,
blobUrl: sas.blobUrl,
@YoonKeumJae
YoonKeumJae requested a review from Copilot June 8, 2026 07:45

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.

Comment on lines +876 to +878
if (!normalizedPostId && !normalizedDraftSessionId) {
return sendError(res, 400, 'BadRequest', 'postId or draftSessionId is required', correlationId);
}
Comment thread Elevate.Admin/src/pages/PostEditor.jsx Outdated
Comment on lines +148 to +149
setMessage(`${flashMessage || '저장되었습니다.'} 첨부파일 연결을 다시 완료했습니다.`)
clearNavigationState()

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 1 comment.

Comment thread Elevate.Admin/src/pages/PostEditor.jsx Outdated
Comment on lines +76 to +88
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()
}
})

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.

Comment on lines +200 to +204
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 }]
};
Comment on lines +123 to +125
useEffect(() => {
setDraftSessionId(isNew ? getDraftSessionId(draftAttachmentStorageKey) : '')
}, [draftAttachmentStorageKey, isNew])

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.

Comment on lines +217 to +228
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);
}
}));
Comment on lines +949 to +959
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);
}));

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 9 out of 9 changed files in this pull request and generated 2 comments.

Comment on lines +337 to +341
if (newId && draftSessionId) {
try {
await linkDraftFilesToPost({ draftSessionId, postId: newId }, { msalInstance })
try { sessionStorage.removeItem(draftAttachmentStorageKey) } catch { /* storage blocked — non-fatal */ }
} catch (linkError) {
Comment on lines 32 to 34
const [status, setStatus] = useState(null) // null | 'uploading' | 'done' | 'error'
const [files, setFiles] = useState([]) // [{ id, fileName, blobUrl, isDeleting }]
const [loadingFiles, setLoadingFiles] = useState(false)

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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',
  })
}

@YoonKeumJae
YoonKeumJae merged commit 6a0c792 into oneot:develop Jun 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants