Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
629d726
fix(auth): secureFetch 토큰 갱신 재요청에서 Content-Type 강제를 제거한다
seongwon030 Sep 5, 2026
fa15fb4
fix(admin): 헤더 로고를 불러오지 못하면 회색 원으로 대체한다
seongwon030 Sep 5, 2026
3cd109a
feat(admin): 관리자 홍보 게시글 관리 화면을 추가한다
seongwon030 Sep 5, 2026
b5ff022
feat(admin): 홍보 게시글 작성 폼의 행사 기간을 오늘로 기본 설정한다
seongwon030 Sep 5, 2026
6f18023
feat(admin): 홍보 게시글 작성 폼의 기본 행사 기간을 다음 정시로 올림한다
seongwon030 Sep 5, 2026
7a7daa0
refactor(admin): 홍보 게시글 심사 판정을 enum 이름 AVAILABLE 하나로 정리한다
seongwon030 Sep 5, 2026
ba24a3a
refactor(admin): 홍보 이미지 업로드를 presigned URL 방식으로 전환한다
seongwon030 Sep 5, 2026
274d2bc
feat(admin): 홍보 게시글 이미지 상한을 15장으로 맞춘다
seongwon030 Sep 6, 2026
e86de4c
test(admin): 헤더 프로필 자리표시 원이 실제로 그려지는지 단언한다
seongwon030 Sep 6, 2026
e8c3490
fix(admin): 홍보 게시글 수정 화면에서 목록 조회 실패를 삭제된 글과 구분한다
seongwon030 Sep 6, 2026
0870834
fix(admin): 홍보 이미지 선택 시점에 지원하지 않는 형식을 거부한다
seongwon030 Sep 6, 2026
bfc76d5
fix(admin): 홍보 게시글 폼이 잠기면 데스크톱 기간 피커도 함께 잠근다
seongwon030 Sep 6, 2026
7ee56bc
fix(admin): 홍보 게시글 수정 중 백그라운드 재조회 실패에 폼이 사라지지 않게 한다
seongwon030 Sep 6, 2026
6c75143
refactor(admin): 이미지 정렬 그리드와 드래그 훅을 관리자 공통 컴포넌트로 올린다
seongwon030 Sep 8, 2026
4677664
feat(admin): 홍보 이미지를 끌어서 순서를 바꿀 수 있게 한다
seongwon030 Sep 8, 2026
534eaf2
feat(promotion): 홍보 카드 표시를 공용 컴포넌트로 분리하고 행사 종료일을 함께 보여준다
seongwon030 Sep 8, 2026
d3814c3
refactor(admin): ApplicationMenu를 AdminMoreMenu로 옮기고 항목을 선택형으로 바꾼다
seongwon030 Sep 8, 2026
932cd09
feat(admin): 홍보 목록을 사용자와 같은 카드 그리드로 바꾼다
seongwon030 Sep 8, 2026
7c78db3
feat(map): 좌표가 정해지기 전에도 지도를 보여줄 수 있게 마커 표시 옵션을 추가한다
seongwon030 Sep 8, 2026
ea0fd40
fix(admin): 홍보 편집 화면의 지도·드롭다운·업로드 실패 안내를 고친다
seongwon030 Sep 8, 2026
30bfa26
style(promotion): 홍보 상세 일시 구분자를 관리자 목록과 같은 물결표로 맞춘다
seongwon030 Sep 8, 2026
af3cf98
Merge remote-tracking branch 'origin/develop-fe' into feature/#2011-a…
seongwon030 Sep 8, 2026
8f86ee3
refactor(admin): 최종 URL 조립 유틸을 홍보·활동 사진이 함께 쓰게 한다
seongwon030 Sep 8, 2026
11da163
fix(admin): 홍보 지도 위치 목록에서 빠지던 좌표를 살린다
seongwon030 Sep 8, 2026
1369d72
fix(promotion): 종료일이 잘못된 값이면 기간 표기가 물결표로 끝나던 문제를 고친다
seongwon030 Sep 9, 2026
210a83b
feat(promotion): 사용자 홍보 화면의 빠진 인터랙션 두 곳에 이벤트를 붙인다
seongwon030 Sep 10, 2026
03d8ad9
fix(admin): 업로드에 실패한 홍보 이미지를 화면에서 구분할 수 있게 한다
seongwon030 Sep 10, 2026
f111db3
refactor(admin): 홍보 편집 입력 필드를 다른 관리자 탭과 같은 모양으로 맞춘다
seongwon030 Sep 14, 2026
98f5066
feat(admin): 홍보 편집 컴팩트 화면에 이 화면이 무엇인지 알려주는 문구를 넣는다
seongwon030 Sep 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/src/apis/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ API는 `src/apis/utils/apiHelpers.ts`의 헬퍼 함수를 사용하는 일관된
- JWT는 localStorage에 저장 (`accessToken` 키, `src/constants/storageKeys.ts`에서 관리)
- 리프레시 토큰은 쿠키로 처리 (`credentials: 'include'`)
- `secureFetch()`가 1차 요청 → 401이면 `refreshAccessToken()`으로 토큰 재발급 후 재요청. refresh 실패 시 `REFRESH_FAILED` 에러
- 재요청도 호출부가 넘긴 headers를 그대로 쓴다. 예전엔 재요청에만 `Content-Type: application/json`을 강제해서 FormData(multipart)를 보내면 브라우저가 붙인 boundary가 덮여 재요청이 깨졌다. 지금은 multipart 호출부가 없지만(홍보 이미지도 presigned로 올린다) 재시도 경로는 1차와 같은 요청이어야 하므로 유지한다. JSON 호출부는 전부 직접 `Content-Type: application/json`을 넘긴다
- 어드민 라우트는 `PrivateRoute` 컴포넌트로 보호

### 익명 학생 토큰 (우체통)
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/apis/auth/secureFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,10 +32,11 @@ export const secureFetch = async (
input,
{
...init,
// Content-Type은 호출부가 정한 값을 그대로 쓴다.
// multipart(FormData)는 브라우저가 boundary를 붙여야 해서 여기서 강제하면 재요청이 깨진다.
headers: {
...(init?.headers || {}),
Authorization: `Bearer ${newAccessToken}`,
'Content-Type': 'application/json',
},
credentials: 'include',
},
Expand Down
187 changes: 182 additions & 5 deletions frontend/src/apis/promotion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,16 @@ import fetchMock from 'jest-fetch-mock';
import {
CreatePromotionArticleRequest,
PromotionArticle,
PromotionPresignedData,
} from '@/types/promotion';
import { createPromotionArticle, getPromotionArticles } from './promotion';
import {
createPromotionArticle,
deletePromotionArticle,
getPromotionArticles,
getPromotionImageUploadUrls,
updatePromotionArticle,
uploadPromotionImageToStorage,
} from './promotion';

jest.mock('@/constants/api', () => ({
__esModule: true,
Expand Down Expand Up @@ -111,16 +119,15 @@ describe('promotion API', () => {
clubId: 'club1',
title: '새로운 홍보글',
location: '서울',
latitude: 35.1,
longitude: 129.1,
eventStartDate: '2024-03-01',
eventEndDate: '2024-03-31',
description: '홍보 내용',
images: ['image1.jpg'],
};

const mockResponse = {
id: '123',
message: '생성 성공',
};
const mockResponse = { articleId: '123' };

fetchMock.mockResponseOnce(JSON.stringify({ data: mockResponse }), {
headers: { 'content-type': 'application/json' },
Expand All @@ -144,6 +151,8 @@ describe('promotion API', () => {
clubId: 'club1',
title: '새로운 홍보글',
location: '부산',
latitude: 35.1,
longitude: 129.1,
eventStartDate: '2024-03-01',
eventEndDate: '2024-03-31',
description: '홍보 내용',
Expand All @@ -159,4 +168,172 @@ describe('promotion API', () => {
);
});
});

describe('updatePromotionArticle', () => {
const payload: CreatePromotionArticleRequest = {
clubId: 'club1',
title: '수정된 홍보글',
location: '부산',
latitude: 35.1,
longitude: 129.1,
eventStartDate: '2024-03-01',
eventEndDate: '2024-03-31',
description: '수정 내용',
images: ['image1.jpg'],
};

it('PUT으로 바디를 보내고 응답 없이 끝난다', async () => {
fetchMock.mockResponseOnce('', { status: 200 });

await expect(
updatePromotionArticle('123', payload),
).resolves.toBeUndefined();

expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/api/promotion/123`,
expect.objectContaining({
method: 'PUT',
body: JSON.stringify(payload),
}),
);
});

it('실패 시 기본 문구로 던지고 서버 문구는 data에 남긴다', async () => {
fetchMock.mockResponseOnce(
JSON.stringify({
statusCode: '902-2',
message: '심사가 완료된 동아리만 홍보 게시글을 작성할 수 있습니다.',
}),
{ status: 403 },
);

await expect(updatePromotionArticle('123', payload)).rejects.toThrow(
'홍보게시판 글 수정에 실패했습니다.',
);
});
});

describe('deletePromotionArticle', () => {
it('DELETE로 요청한다', async () => {
fetchMock.mockResponseOnce('', { status: 200 });

await expect(deletePromotionArticle('123')).resolves.toBeUndefined();

expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/api/promotion/123`,
expect.objectContaining({ method: 'DELETE' }),
);
});

it('404면 에러를 던진다', async () => {
fetchMock.mockResponseOnce(
JSON.stringify({ statusCode: '902-1', message: '없는 글' }),
{ status: 404 },
);

await expect(deletePromotionArticle('missing')).rejects.toThrow(
'홍보게시판 글 삭제에 실패했습니다.',
);
});
});

describe('getPromotionImageUploadUrls', () => {
it('파일 목록을 배열로 보내고 항목별 발급 결과를 돌려준다', async () => {
const presigned: PromotionPresignedData[] = [
{
presignedUrl: 'https://r2/put?sig=1',
finalUrl: 'https://cdn/promotion/articles/123/2026/09/a.png',
requiredHeaders: { 'Content-Type': 'image/png' },
success: true,
failureReason: null,
},
{
presignedUrl: null,
finalUrl: '',
requiredHeaders: {},
success: false,
failureReason: '허용되지 않는 형식',
},
];
fetchMock.mockResponseOnce(JSON.stringify({ data: presigned }), {
headers: { 'content-type': 'application/json' },
});
const requests = [
{ fileName: 'a.png', contentType: 'image/png' },
{ fileName: 'b.svg', contentType: 'image/svg+xml' },
];

const result = await getPromotionImageUploadUrls('123', requests);

// 한 항목이 실패해도 배열 전체를 실패로 보지 않는다
expect(result).toEqual(presigned);
expect(fetchMock).toHaveBeenCalledWith(
`${API_BASE_URL}/api/promotion/123/upload-url`,
expect.objectContaining({
method: 'POST',
body: JSON.stringify(requests),
}),
);
});

it('남의 동아리 글이면 에러를 던진다', async () => {
fetchMock.mockResponseOnce(JSON.stringify({ message: '권한 없음' }), {
status: 403,
});

await expect(
getPromotionImageUploadUrls('123', [
{ fileName: 'a.png', contentType: 'image/png' },
]),
).rejects.toThrow('홍보 이미지 업로드 URL 생성에 실패했습니다.');
});
});

describe('uploadPromotionImageToStorage', () => {
const file = new File(['x'], 'a.png', { type: 'image/png' });
const presigned: PromotionPresignedData = {
presignedUrl: 'https://r2/put?sig=1',
finalUrl: 'https://cdn/a.png',
requiredHeaders: { 'Content-Type': 'image/png' },
success: true,
failureReason: null,
};

it('presigned URL에 requiredHeaders만 실어 PUT하고 Authorization은 붙이지 않는다', async () => {
fetchMock.mockResponseOnce('', { status: 200 });

await expect(
uploadPromotionImageToStorage(presigned, file),
).resolves.toBeUndefined();

const [url, options] = fetchMock.mock.calls[0];
expect(url).toBe('https://r2/put?sig=1');
expect(options?.method).toBe('PUT');
expect(options?.body).toBe(file);
expect(options?.headers).toEqual({ 'Content-Type': 'image/png' });
});

it('발급이 실패한 항목은 요청 없이 사유로 던진다', async () => {
await expect(
uploadPromotionImageToStorage(
{
...presigned,
presignedUrl: null,
success: false,
failureReason: '허용되지 않는 형식',
},
file,
),
).rejects.toThrow('허용되지 않는 형식');
expect(fetchMock).not.toHaveBeenCalled();
});

it('스토리지가 거부하면 에러를 던진다', async () => {
fetchMock.mockResponseOnce('', { status: 403 });

await expect(
uploadPromotionImageToStorage(presigned, file),
).rejects.toThrow('스토리지 업로드 실패 : 403');
});
});
});
81 changes: 80 additions & 1 deletion frontend/src/apis/promotion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { festivalMock } from '@/mocks/data/festivalMock';
import { sortPromotions } from '@/pages/PromotionPage/utils/sortPromotions';
import {
CreatePromotionArticleRequest,
CreatePromotionArticleResponse,
PromotionArticle,
PromotionImageUploadRequest,
PromotionPresignedData,
UpdatePromotionArticleRequest,
} from '@/types/promotion';
import { secureFetch } from './auth/secureFetch';
import { handleResponse } from './utils/apiHelpers';
Expand Down Expand Up @@ -41,5 +45,80 @@ export const createPromotionArticle = async (
body: JSON.stringify(payload),
Comment thread
seongwon030 marked this conversation as resolved.
});

return handleResponse(response, '홍보게시판 글 추가에 실패했습니다.');
return handleResponse<CreatePromotionArticleResponse>(
response,
'홍보게시판 글 추가에 실패했습니다.',
);
};

export const updatePromotionArticle = async (
articleId: string,
payload: UpdatePromotionArticleRequest,
) => {
const response = await secureFetch(
`${API_BASE_URL}/api/promotion/${articleId}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
},
);

await handleResponse(response, '홍보게시판 글 수정에 실패했습니다.');
};

export const deletePromotionArticle = async (articleId: string) => {
const response = await secureFetch(
`${API_BASE_URL}/api/promotion/${articleId}`,
{ method: 'DELETE' },
);

await handleResponse(response, '홍보게시판 글 삭제에 실패했습니다.');
};

/**
* 이미지는 글을 먼저 만들어 articleId를 받은 뒤 presigned URL을 발급받아 R2에 직접 올린다.
* 발급 API는 게시글을 건드리지 않으므로 올린 finalUrl은 PUT의 images로 반영해야 한다.
* 항목별로 success가 갈릴 수 있어 배열 전체를 실패로 보지 않는다.
*/
export const getPromotionImageUploadUrls = async (
articleId: string,
requests: PromotionImageUploadRequest[],
) => {
const response = await secureFetch(
`${API_BASE_URL}/api/promotion/${articleId}/upload-url`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(requests),
},
);

return handleResponse<PromotionPresignedData[]>(
response,
'홍보 이미지 업로드 URL 생성에 실패했습니다.',
);
};

/**
* R2로 직접 나가는 요청이라 secureFetch를 쓰지 않는다. Authorization이 붙으면 서명 검증에 걸린다.
* requiredHeaders는 서명에 포함된 값이라 빠짐없이 그대로 보낸다.
*/
export const uploadPromotionImageToStorage = async (
presigned: PromotionPresignedData,
file: File,
) => {
if (!presigned.presignedUrl) {
throw new Error(presigned.failureReason ?? 'presigned URL 생성 실패');
}
const response = await fetch(presigned.presignedUrl, {
method: 'PUT',
body: file,
headers: presigned.requiredHeaders,
});
await handleResponse(response, `스토리지 업로드 실패 : ${response.status}`);
};
2 changes: 1 addition & 1 deletion frontend/src/components/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ common/Toast/

- 스타일은 `* as Styled`로 import: `import * as Styled from './Toast.styles'`
- styled-components에 넘기는 커스텀 prop은 **`$` 접두사(transient prop)**. DOM에 새어나가지 않는다. 예: `$isActive`, `$duration`
- `src/components/`에는 테스트 파일이 없다. 검증은 Storybook + `npm run typecheck`로 한다 (루트 `CLAUDE.md`의 Storybook 가이드 참고)
- `src/components/`는 기본적으로 Storybook + `npm run typecheck`로 검증한다 (루트 `CLAUDE.md`의 Storybook 가이드 참고). 시각으로 확인하기 어려운 분기(이미지 로드 실패 등)만 예외적으로 RTL 테스트를 둔다 (`common/Header/admin/AdminProfile.test.tsx`)

## 스타일 하드룰

Expand Down
14 changes: 13 additions & 1 deletion frontend/src/components/common/Header/Header.styles.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import styled from 'styled-components';
import { media } from '@/styles/mediaQuery';
import { colors } from '@/styles/theme/colors';
import { Z_INDEX } from '@/styles/zIndex';

export const HEADER_HEIGHT = {
Expand Down Expand Up @@ -140,6 +141,17 @@ export const AdminProfileText = styled.div`

export const AdminProfileImage = styled.img`
width: 40px;
height: auto;
height: 40px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
`;

/** 로고가 없거나 불러오지 못했을 때 자리를 지키는 회색 원 */
export const AdminProfilePlaceholder = styled.div`
width: 40px;
height: 40px;
border-radius: 50%;
background-color: ${colors.gray[300]};
flex-shrink: 0;
`;
Loading
Loading