export type OrgStatus = 'INFO_PENDING' | 'READY' | 'PARTIAL' | 'ACTIVE' export interface Org { id: number orgNo: string orgName: string status: OrgStatus deptName: string | null managerName: string | null managerTitle: string | null managerPhone: string | null managerEmail: string | null channelIdMj: string | null channelIdLaw: string | null } export interface Contact { deptName: string managerName: string managerTitle: string managerPhone: string managerEmail: string } export type ProvisionOutcome = 'CACHED' | 'RECOVERED' | 'CREATED' | 'FAILED' export interface ProvisionResult { mj: ProvisionOutcome law: ProvisionOutcome message: string } export interface SeedReport { created: number updated: number total: number } export interface FileRef { id: string name: string size: number } export interface PostView { id: string user: string message: string createAt: number system: boolean files: FileRef[] } export interface FileView { id: string name: string size: number mimeType: string createAt: number uploader: string } export type ChannelKindKey = 'mj' | 'law' export interface WorkMemo { id: number orgId: number contactName: string body: string createdAt: number } /** * 서버가 4xx/5xx로 응답했을 때 던지는 예외. status를 함께 들고 있어야 호출자가 * "인증이 끊긴 것(401)"과 "그 밖의 모든 오류"를 구분해서 다르게 처리할 수 있다 - * 그렇지 않으면(예: 평범한 Error) 서버 오류나 네트워크 문제까지 전부 로그아웃으로 * 오인해 사용자를 로그인 화면으로 쫓아내게 된다. */ export class ApiError extends Error { constructor( public status: number, message: string, ) { super(message) this.name = 'ApiError' } } /** Spring Security가 내려주는 CSRF 쿠키를 읽어 헤더로 되돌려 보낸다. */ function csrfToken(): string { const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]*)/) return match ? decodeURIComponent(match[1]) : '' } async function request(url: string, init: RequestInit = {}): Promise { const method = init.method ?? 'GET' const headers: Record = { ...(init.headers as Record) } if (method !== 'GET') { headers['X-XSRF-TOKEN'] = csrfToken() } const response = await fetch(url, { ...init, method, headers, credentials: 'same-origin' }) if (!response.ok) { const body = await response.text() throw new ApiError(response.status, `요청 실패 (${response.status}): ${body}`) } return response.json() as Promise } export function getOrgs(): Promise { return request('/api/orgs') } export function updateContact(id: number, contact: Contact): Promise { return request(`/api/orgs/${id}/contact`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(contact), }) } export function provisionChannels(id: number): Promise { return request(`/api/orgs/${id}/channels`, { method: 'POST' }) } export function getPosts(orgId: number, channel: ChannelKindKey): Promise { return request(`/api/orgs/${orgId}/posts?channel=${channel}`) } export function getFiles(orgId: number, channel: ChannelKindKey): Promise { return request(`/api/orgs/${orgId}/files?channel=${channel}`) } /** 다운로드는 가 그대로 열게 두므로 fetch()를 거치지 않는다. */ export function fileDownloadUrl(fileId: string): string { return `/api/files/${fileId}` } export interface SendResult { post: PostView /** 게시글은 전송됐지만 공지 뒤처리(핀/헤더)가 일부 실패한 경우의 경고. 성공이면 null */ noticeWarning: string | null } export function sendMessage( orgId: number, channel: ChannelKindKey, body: { message: string; notice: boolean; files: File[] }, ): Promise { const form = new FormData() form.append('channel', channel) form.append('message', body.message) form.append('notice', String(body.notice)) for (const file of body.files) { form.append('files', file) } return request(`/api/orgs/${orgId}/messages`, { method: 'POST', body: form }) } /** 공지가 없으면 서버가 204를 주므로 request()의 json() 경로를 타지 않고 직접 부른다. */ export async function getNotice(orgId: number, channel: ChannelKindKey): Promise { const response = await fetch(`/api/orgs/${orgId}/notice?channel=${channel}`, { credentials: 'same-origin', }) if (response.status === 204) { return null } if (!response.ok) { throw new ApiError(response.status, `요청 실패 (${response.status})`) } return response.json() as Promise } export async function clearNotice(orgId: number, channel: ChannelKindKey): Promise { const response = await fetch(`/api/orgs/${orgId}/notice?channel=${channel}`, { method: 'DELETE', credentials: 'same-origin', headers: { 'X-XSRF-TOKEN': csrfToken() }, }) if (!response.ok) { throw new ApiError(response.status, `공지 해제 실패 (${response.status})`) } } export function getMemos(orgId: number): Promise { return request(`/api/orgs/${orgId}/memos`) } export function createMemo( orgId: number, memo: { contactName: string; body: string }, ): Promise { return request(`/api/orgs/${orgId}/memos`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(memo), }) } /** 204를 돌려주므로 request()의 json() 경로를 타지 않고 직접 부른다(logout()과 동일한 패턴). */ export async function deleteMemo(orgId: number, memoId: number): Promise { const response = await fetch(`/api/orgs/${orgId}/memos/${memoId}`, { method: 'DELETE', credentials: 'same-origin', headers: { 'X-XSRF-TOKEN': csrfToken() }, }) if (!response.ok) { const body = await response.text() throw new ApiError(response.status, `요청 실패 (${response.status}): ${body}`) } } export function uploadSeed(file: File): Promise { const form = new FormData() form.append('file', file) return request('/api/seed', { method: 'POST', body: form }) } /** 204를 돌려주므로 request()의 json() 경로를 타지 않고 직접 부른다. */ export async function logout(): Promise { const response = await fetch('/api/auth/logout', { method: 'POST', credentials: 'same-origin', headers: { 'X-XSRF-TOKEN': csrfToken() }, }) if (!response.ok) { throw new ApiError(response.status, `로그아웃 실패 (${response.status})`) } } export async function login(username: string, password: string): Promise { const body = new URLSearchParams({ username, password }) const response = await fetch('/api/auth/login', { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-XSRF-TOKEN': csrfToken(), }, body, }) if (!response.ok) { throw new ApiError(response.status, '아이디 또는 비밀번호가 올바르지 않습니다.') } }