From 9f761849c2e98f650d089d00aed9df090497ada9 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Mon, 27 Oct 2025 03:12:26 +0000 Subject: (최겸) 공지사항 팝업기능 및 다시보지않기 기능 구현(로컬 스토리지 활용) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/notice/repository.ts | 23 ++++- lib/notice/service.ts | 35 +++++++- lib/notice/storage-utils.ts | 213 ++++++++++++++++++++++++++++++++++++++++++++ lib/notice/validations.ts | 44 +++++++++ 4 files changed, 311 insertions(+), 4 deletions(-) create mode 100644 lib/notice/storage-utils.ts (limited to 'lib/notice') diff --git a/lib/notice/repository.ts b/lib/notice/repository.ts index fb941ac9..897eb5b0 100644 --- a/lib/notice/repository.ts +++ b/lib/notice/repository.ts @@ -2,8 +2,10 @@ import { desc, eq, and, sql } from "drizzle-orm" import db from "@/db/db" import { notice, users, type Notice, type NewNotice } from "@/db/schema" -// 페이지 경로별 공지사항 조회 (활성화된 것만, 작성자 정보 포함) +// 페이지 경로별 공지사항 조회 (활성화된 것만, 작성자 정보 포함, 유효기간 내 공지사항만) export async function getNoticesByPagePath(pagePath: string): Promise> { + const currentTime = new Date() + const result = await db .select({ id: notice.id, @@ -12,6 +14,10 @@ export async function getNoticesByPagePath(pagePath: string): Promise= ${currentTime})` )) .orderBy(desc(notice.createdAt)) @@ -32,7 +41,11 @@ export async function getNoticesByPagePath(pagePath: string): Promise { const result = await db .insert(notice) - .values(data) + .values({ + ...data, + createdAt: new Date(), + updatedAt: new Date() + }) .returning() return result[0] @@ -77,6 +90,10 @@ export async function getNoticeById(id: number): Promise<(Notice & { authorName: content: notice.content, authorId: notice.authorId, isActive: notice.isActive, + isPopup: notice.isPopup, + startAt: notice.startAt, + endAt: notice.endAt, + dontShowDuration: notice.dontShowDuration, createdAt: notice.createdAt, updatedAt: notice.updatedAt, authorName: users.name, diff --git a/lib/notice/service.ts b/lib/notice/service.ts index 9c05b98f..12f2ed2e 100644 --- a/lib/notice/service.ts +++ b/lib/notice/service.ts @@ -21,6 +21,23 @@ import { import type { Notice } from "@/db/schema/notice" +// 페이지별 공지사항 조회 (강제 모달용) +export async function getPageNoticesForModal(pagePath: string): Promise> { + try { + console.log('🔍 Notice Service - 모달용 조회 시작:', { pagePath }) + const result = await getNoticesByPagePath(pagePath) + console.log('📊 Notice Service - 모달용 조회 결과:', { + pagePath, + noticesCount: result.length, + notices: result.map(n => ({ id: n.id, title: n.title, pagePath: n.pagePath })) + }) + return result + } catch (error) { + console.error(`Failed to get notices for modal on page ${pagePath}:`, error) + return [] + } +} + // 간단한 공지사항 목록 조회 (페이지네이션 없이 전체 조회) export async function getNoticeLists(): Promise<{ data: Array }> { try { @@ -33,6 +50,10 @@ export async function getNoticeLists(): Promise<{ data: Array { try { - return await getNoticeById(id) + const result = await getNoticeById(id) + // 유효기간 검증 추가 (현재 시간이 유효기간 내에 있는지 확인) + if (result) { + const currentTime = new Date() + const isValid = (!result.startAt || result.startAt <= currentTime) && + (!result.endAt || result.endAt >= currentTime) + + if (!isValid) { + console.log(`Notice ${id} is not in valid time range`) + return null + } + } + return result } catch (error) { console.error(`Failed to get notice detail for id ${id}:`, error) return null diff --git a/lib/notice/storage-utils.ts b/lib/notice/storage-utils.ts new file mode 100644 index 00000000..41a512d8 --- /dev/null +++ b/lib/notice/storage-utils.ts @@ -0,0 +1,213 @@ +/** + * 공지사항 로컬 스토리지 유틸리티 함수들 + * '다시 보지 않기' 기능을 위한 저장 및 확인 로직을 제공합니다. + */ + +export interface NoticeStorageOptions { + noticeId: number + duration: 'day' | 'never' +} + +/** + * 공지사항 '다시 보지 않기' 설정을 로컬 스토리지에 저장합니다. + * @param options - 공지사항 ID와 기간 설정 + */ +export function setNoticeDontShow(options: NoticeStorageOptions): void { + const { noticeId, duration } = options + + if (typeof window === 'undefined') { + console.warn('setNoticeDontShow: window 객체가 없습니다. 클라이언트 사이드에서만 사용 가능합니다.') + return + } + + try { + const key = `notice-${noticeId}-${duration}` + let value: string + + if (duration === 'day') { + // 오늘 자정까지의 타임스탬프 계산 + const today = new Date() + const tomorrow = new Date(today) + tomorrow.setDate(tomorrow.getDate() + 1) + tomorrow.setHours(0, 0, 0, 0) // 자정으로 설정 + + value = tomorrow.getTime().toString() + } else if (duration === 'never') { + // 영구적으로 저장 (매우 먼 미래의 날짜) + const neverDate = new Date('2099-12-31T23:59:59.999Z') + value = neverDate.getTime().toString() + } else { + throw new Error(`지원하지 않는 기간 설정입니다: ${duration}`) + } + + localStorage.setItem(key, value) + console.log(`공지사항 ${noticeId}의 '다시 보지 않기' 설정을 저장했습니다:`, { key, value, duration }) + } catch (error) { + console.error('공지사항 설정 저장 중 오류 발생:', error) + } +} + +/** + * 공지사항의 '다시 보지 않기' 설정이 유효한지 확인합니다. + * @param options - 공지사항 ID와 기간 설정 + * @returns 설정이 유효하면 true, 만료되었거나 설정되지 않았으면 false + */ +export function isNoticeDontShowValid(options: NoticeStorageOptions): boolean { + const { noticeId, duration } = options + + if (typeof window === 'undefined') { + console.warn('isNoticeDontShowValid: window 객체가 없습니다. 클라이언트 사이드에서만 사용 가능합니다.') + return false + } + + try { + const key = `notice-${noticeId}-${duration}` + const storedValue = localStorage.getItem(key) + + if (!storedValue) { + return false // 설정이 없음 + } + + const expirationTime = parseInt(storedValue, 10) + const currentTime = Date.now() + + if (isNaN(expirationTime)) { + console.warn(`잘못된 만료 시간 값입니다: ${storedValue}`) + localStorage.removeItem(key) // 잘못된 값은 삭제 + return false + } + + const isValid = currentTime < expirationTime + + if (!isValid) { + // 만료된 설정은 자동으로 삭제 + localStorage.removeItem(key) + console.log(`공지사항 ${noticeId}의 만료된 설정을 삭제했습니다.`) + } + + return isValid + } catch (error) { + console.error('공지사항 설정 확인 중 오류 발생:', error) + return false + } +} + +/** + * 특정 공지사항의 모든 '다시 보지 않기' 설정을 초기화합니다. + * @param noticeId - 공지사항 ID + */ +export function clearNoticeDontShowSettings(noticeId: number): void { + if (typeof window === 'undefined') { + console.warn('clearNoticeDontShowSettings: window 객체가 없습니다. 클라이언트 사이드에서만 사용 가능합니다.') + return + } + + try { + const keysToRemove: string[] = [] + + // 모든 가능한 키 패턴을 확인 + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i) + if (key && key.startsWith(`notice-${noticeId}-`)) { + keysToRemove.push(key) + } + } + + // 모든 관련 키 삭제 + keysToRemove.forEach(key => localStorage.removeItem(key)) + + if (keysToRemove.length > 0) { + console.log(`공지사항 ${noticeId}의 모든 '다시 보지 않기' 설정을 초기화했습니다:`, keysToRemove) + } + } catch (error) { + console.error('공지사항 설정 초기화 중 오류 발생:', error) + } +} + +/** + * 모든 공지사항의 '다시 보지 않기' 설정을 초기화합니다. + * 주의: 이 함수는 모든 공지사항 설정을 삭제하므로 신중하게 사용해야 합니다. + */ +export function clearAllNoticeDontShowSettings(): void { + if (typeof window === 'undefined') { + console.warn('clearAllNoticeDontShowSettings: window 객체가 없습니다. 클라이언트 사이드에서만 사용 가능합니다.') + return + } + + try { + const keysToRemove: string[] = [] + + // 모든 가능한 키 패턴을 확인 + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i) + if (key && key.startsWith('notice-')) { + keysToRemove.push(key) + } + } + + // 모든 관련 키 삭제 + keysToRemove.forEach(key => localStorage.removeItem(key)) + + if (keysToRemove.length > 0) { + console.log(`모든 공지사항의 '다시 보지 않기' 설정을 초기화했습니다: ${keysToRemove.length}개`) + } + } catch (error) { + console.error('모든 공지사항 설정 초기화 중 오류 발생:', error) + } +} + +/** + * 현재 로컬 스토리지에서 공지사항과 관련된 모든 키를 조회합니다. + * 디버깅 목적으로 사용됩니다. + */ +export function getAllNoticeStorageKeys(): string[] { + if (typeof window === 'undefined') { + console.warn('getAllNoticeStorageKeys: window 객체가 없습니다. 클라이언트 사이드에서만 사용 가능합니다.') + return [] + } + + try { + const keys: string[] = [] + + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i) + if (key && key.startsWith('notice-')) { + keys.push(key) + } + } + + return keys + } catch (error) { + console.error('공지사항 키 조회 중 오류 발생:', error) + return [] + } +} + +/** + * 특정 공지사항의 현재 설정 상태를 조회합니다. + * 디버깅 목적으로 사용됩니다. + */ +export function getNoticeStorageInfo(noticeId: number): Array<{ key: string; value: string | null; duration: string }> { + if (typeof window === 'undefined') { + console.warn('getNoticeStorageInfo: window 객체가 없습니다. 클라이언트 사이드에서만 사용 가능합니다.') + return [] + } + + try { + const info: Array<{ key: string; value: string | null; duration: string }> = [] + + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i) + if (key && key.startsWith(`notice-${noticeId}-`)) { + const value = localStorage.getItem(key) + const duration = key.split('-').pop() || 'unknown' + info.push({ key, value, duration }) + } + } + + return info + } catch (error) { + console.error('공지사항 정보 조회 중 오류 발생:', error) + return [] + } +} diff --git a/lib/notice/validations.ts b/lib/notice/validations.ts index 146f8e09..6fca1bea 100644 --- a/lib/notice/validations.ts +++ b/lib/notice/validations.ts @@ -7,6 +7,28 @@ export const createNoticeSchema = z.object({ content: z.string().min(1, "내용을 입력해주세요"), authorId: z.number().min(1, "작성자를 선택해주세요"), isActive: z.boolean().default(true), + isPopup: z.boolean().default(false), + startAt: z.date().optional(), + endAt: z.date().optional(), + dontShowDuration: z.string().transform((val) => val as 'day' | 'never').optional().default('never'), +}).refine((data) => { + // 팝업인 경우 시작일시와 종료일시는 필수 + if (data.isPopup) { + return data.startAt && data.endAt; + } + return true; +}, { + message: "팝업 공지사항은 시작일시와 종료일시를 모두 설정해야 합니다.", + path: ["startAt"], +}).refine((data) => { + // 팝업인 경우에만 시작일시와 종료일시가 모두 설정된 경우 종료일이 시작일보다 과거인지 확인 + if (data.isPopup && data.startAt && data.endAt) { + return data.endAt >= data.startAt; + } + return true; +}, { + message: "종료일시는 시작일시보다 과거일 수 없습니다.", + path: ["endAt"], }) // 공지사항 수정 스키마 @@ -16,6 +38,28 @@ export const updateNoticeSchema = z.object({ title: z.string().min(1, "제목을 입력해주세요"), content: z.string().min(1, "내용을 입력해주세요"), isActive: z.boolean().default(true), + isPopup: z.boolean().default(false), + startAt: z.date().optional(), + endAt: z.date().optional(), + dontShowDuration: z.string().transform((val) => val as 'day' | 'never').optional().default('never'), +}).refine((data) => { + // 팝업인 경우 시작일시와 종료일시는 필수 + if (data.isPopup) { + return data.startAt && data.endAt; + } + return true; +}, { + message: "팝업 공지사항은 시작일시와 종료일시를 모두 설정해야 합니다.", + path: ["startAt"], +}).refine((data) => { + // 팝업인 경우에만 시작일시와 종료일시가 모두 설정된 경우 종료일이 시작일보다 과거인지 확인 + if (data.isPopup && data.startAt && data.endAt) { + return data.endAt >= data.startAt; + } + return true; +}, { + message: "종료일시는 시작일시보다 과거일 수 없습니다.", + path: ["endAt"], }) // 페이지 경로별 공지사항 조회 스키마 -- cgit v1.2.3